callkaro-cli-unofficial 0.1.0 → 0.2.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 CHANGED
@@ -2,11 +2,12 @@
2
2
 
3
3
  An unofficial fork of the [CallKaro CLI](https://www.npmjs.com/package/@callkaro-official/cli). It installs as `cku`, so it sits alongside the official `ck` without either one overwriting the other.
4
4
 
5
- Manage voice agents, calls, phone numbers, batch campaigns, simulations and analytics from the terminal.
5
+ Manage voice agents, calls, phone numbers, batch campaigns, simulations and analytics from the terminal — plus the dashboard side, which the official CLI doesn't reach at all.
6
6
 
7
7
  ```
8
8
  cku agents list
9
9
  cku calls make --agent <id> --to <number> --test
10
+ cku kpi tasks --severity "Very High" --column action_items
10
11
  cku accounts use tejas@acme.com
11
12
  ```
12
13
 
@@ -14,12 +15,12 @@ cku accounts use tejas@acme.com
14
15
 
15
16
  ## Why this exists
16
17
 
17
- Three reasons, all of them things the official CLI can't do.
18
-
19
18
  **It doesn't fight the official CLI for a name.** Both packages write to the same global `node_modules/.bin`. If both were called `ck`, whichever npm install ran last would silently take over. You'd type `ck`, get official behaviour, and get no signal anything had changed. `cku` cannot collide.
20
19
 
21
20
  **It holds several accounts at once.** The official CLI keeps one session. This one keeps a file per account, and can pin an account to a folder, so two agents working in two directories can't switch each other out from under themselves.
22
21
 
22
+ **It reaches the dashboard API.** The official CLI only talks to `/cli/*`. That leaves the KPI board, the call analyzers, and the server's own model catalogue unreachable from a terminal — [`cku kpi`](#only-in-cku) is the whole of that surface.
23
+
23
24
  **It fixes three upstream bugs.** Details and evidence in [`docs/BUG-REPORT.md`](docs/BUG-REPORT.md).
24
25
 
25
26
  ---
@@ -68,7 +69,7 @@ Every arrow above is a real import or a real call, checked against the source. `
68
69
  /cli/* · 61 endpoints · Bearer ck_5b02…
69
70
  ```
70
71
 
71
- One thing worth knowing up front: the official CLI ships **compiled output only**. Its npm package has `dist/` and nothing else: no TypeScript, no tests, no build config, and it isn't in any public repo. So this is a reconstruction. The `/cli/*` contract was rebuilt from the published bundle, and the command surface matches v4.0.0 exactly (44 subcommands against the official 40; the four extras are the `accounts` group).
72
+ One thing worth knowing up front: the official CLI ships **compiled output only**. Its npm package has `dist/` and nothing else: no TypeScript, no tests, no build config, and it isn't in any public repo. So this is a reconstruction. The `/cli/*` contract was rebuilt from the published bundle, and the command surface matches v4.0.1 (the official CLI's commands plus the `accounts` group and the dashboard-side `dashboard`, `kpi`, `analyzers` and `models` groups).
72
73
 
73
74
  The public API at `api.callkaro.ai` is a different thing entirely, with an `X-API-KEY` header and three documented endpoints. This CLI doesn't use it. [`docs/architecture.md`](docs/architecture.md) covers the boundaries, and [`docs/api.md`](docs/api.md) has the full endpoint inventory.
74
75
 
@@ -120,6 +121,69 @@ My own first port of this fix had a worse version of the same bug: I merged agai
120
121
 
121
122
  ---
122
123
 
124
+ ## Only in `cku`
125
+
126
+ Commands and capabilities the official CLI does not have at all. Not bug fixes — surface area.
127
+
128
+ ### The KPI manager and the pending-task board
129
+
130
+ The official `ck` has **no KPI commands whatsoever**. This is the only way to reach the board from a terminal.
131
+
132
+ ```bash
133
+ cku kpi tasks --severity "Very High" --column action_items --since 7d
134
+ cku kpi show "<taskId>" # cause, recommended fix, metric impact
135
+ cku kpi create --cause "…" --fix "…" --severity High
136
+ cku kpi move "<taskId>" in_progress
137
+ cku kpi comment "<taskId>" "Fixed in v3"
138
+ cku kpi assign "<taskId>" --to "Name" # or --to ai_fde
139
+ cku kpi target --agent <id> --value 25
140
+ cku kpi rca --agent <id> --since 2026-09-01 --until 2026-09-30
141
+ ```
142
+
143
+ There is **no delete** for a card — the board retires one by moving it to `rejected`, which is reversible. Only `description` is editable after creation; `cause`, fix and severity are fixed at create, so correcting one means creating a replacement.
144
+
145
+ `GET /pending-tasks` returns every column including `done` and `rejected`, so a closed card is not a gone card. The list prints a **COLUMN** column for exactly that reason, and `--column` filters on it.
146
+
147
+ ### The dashboard credential
148
+
149
+ ```bash
150
+ cku dashboard login # separate from `cku login`; the token lasts 24h, renewed silently
151
+ cku dashboard whoami
152
+ cku dashboard probe # which dashboard routes this token reaches
153
+ ```
154
+
155
+ The dashboard API needs a *different* token from `/cli/*` — they're not interchangeable, and each rejects the other with an opaque 401. This also unlocks `cku analyzers` and `cku models`. The dashboard token expires every 24h with no refresh endpoint, so `cku dashboard login` saves your password (plain text, mode 0600, alongside the tokens) and re-logs in for you when it lapses. Pass `--no-save-password` to keep it off disk and re-enter it daily; `cku dashboard logout` deletes it.
156
+
157
+ ### Knowing which models are actually valid
158
+
159
+ ```bash
160
+ cku models # per slot, and which field each governs
161
+ cku models --slot agent
162
+ ```
163
+
164
+ Models are scoped per slot and **the slots disagree**: `agent` has 44, `post-call` has 9 and excludes `gpt-4o` and `o4-mini`. So the same value is valid in `model` and refused in `postcallmodel`. Works with or without a dashboard session, and says which source it read.
165
+
166
+ ### A catalogue that can't silently rot
167
+
168
+ The provider tables, and now the model lists, are regenerated from the server rather than hand-copied:
169
+
170
+ ```bash
171
+ npm run catalog:sync # refresh from the bundle + the live server
172
+ npm run catalog:check # fail if catalog.json disagrees with the snapshot
173
+ ```
174
+
175
+ `catalog:check` runs offline against a committed snapshot, so it works in CI with no credentials. On 1 October 2026 it caught shipped tables that listed withdrawn models *and* missed live ones in both directions. A wrong voice model otherwise persists silently and only surfaces as a broken call.
176
+
177
+ ### Smaller things
178
+
179
+ - `cku calls logs` — grep/awk/sed over the raw call log, in-process, no subprocess and no shell
180
+ - `cku accounts status` — reports which account is in effect **and where that choice came from**
181
+ - Per-project binding, so parallel agents can't redirect each other
182
+ - `secrets set` without the value on the command line
183
+ - `--json` that stays unpaged for scripting, with human tables paged
184
+
185
+ ---
186
+
123
187
  ## Staying current
124
188
 
125
189
  A daily workflow checks npm for a newer `@callkaro-official/cli` and, when it finds one, regenerates the provider catalogue and opens a PR onto the `official` branch. It never merges anything itself.
@@ -34,6 +34,7 @@ async function loginCommand(opts) {
34
34
  }
35
35
  try {
36
36
  const { api } = (0, config_1.getClient)();
37
+ (0, ui_1.needTty)('Use `cku login` (browser sign-in) or set CK_TOKEN.');
37
38
  const answers = await (0, prompts_1.default)([
38
39
  { type: opts.email ? null : 'text', name: 'email', message: 'Email' },
39
40
  { type: 'password', name: 'password', message: 'Password' },
@@ -58,6 +59,7 @@ async function registerCommand(opts) {
58
59
  return;
59
60
  }
60
61
  const { api } = (0, config_1.getClient)();
62
+ (0, ui_1.needTty)('Registration is interactive (it sends a verification code); run it in a terminal.');
61
63
  const answers = await (0, prompts_1.default)([
62
64
  { type: opts.email ? null : 'text', name: 'email', message: 'Email' },
63
65
  { type: opts.name ? null : 'text', name: 'name', message: 'Your name' },
@@ -254,16 +256,8 @@ async function accountsRemoveCommand(emailOrSlug, opts) {
254
256
  accounts.find((a) => (0, config_1.slugFor)(a.email || '').startsWith(wanted));
255
257
  if (!match)
256
258
  return (0, ui_1.handleError)(new Error(`No saved account matches "${emailOrSlug}".`));
257
- if (!opts.yes) {
258
- const { ok } = await (0, prompts_1.default)({
259
- type: 'confirm',
260
- name: 'ok',
261
- message: `Remove the saved login for ${match.email}?`,
262
- initial: false,
263
- });
264
- if (!ok)
265
- return void (0, ui_1.info)('Cancelled.');
266
- }
259
+ if (!(await (0, ui_1.confirm)(`Remove the saved login for ${match.email}?`, opts.yes)))
260
+ return;
267
261
  const slug = (0, config_1.slugFor)(match.email || '');
268
262
  (0, config_1.removeAccount)(slug);
269
263
  (0, ui_1.success)(`Removed ${match.email}.`);
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.setInboundCommand = setInboundCommand;
4
+ exports.setWhatsappOutboundCommand = setWhatsappOutboundCommand;
5
+ exports.setWhatsappInboundCommand = setWhatsappInboundCommand;
4
6
  exports.setOutboundCommand = setOutboundCommand;
5
7
  const session_1 = require("../lib/session");
6
8
  const ui_1 = require("../lib/ui");
@@ -33,6 +35,42 @@ async function setInboundCommand(agentId, opts) {
33
35
  (0, ui_1.handleError)(err);
34
36
  }
35
37
  }
38
+ /**
39
+ * `cku agents set-whatsapp-outbound <agentId> --number <id>` (or `--clear`)
40
+ *
41
+ * Reuses PUT /cli/agents/:id, like voice outbound. The id is the WhatsApp phone
42
+ * number id from `cku whatsapp list`, not the display number.
43
+ */
44
+ async function setWhatsappOutboundCommand(agentId, opts) {
45
+ const api = (0, session_1.requireApi)();
46
+ if (!opts.clear && !opts.number)
47
+ return (0, ui_1.handleError)(new Error('--number <phoneNumberId> or --clear is required'));
48
+ try {
49
+ await api.updateAgent(agentId, undefined, { whatsappPhoneNumber: opts.clear ? null : opts.number });
50
+ (0, ui_1.success)(opts.clear ? `Cleared WhatsApp outbound number on agent ${agentId}.` : `Set WhatsApp outbound number on agent ${agentId}.`);
51
+ }
52
+ catch (err) {
53
+ (0, ui_1.handleError)(err);
54
+ }
55
+ }
56
+ /**
57
+ * `cku agents set-whatsapp-inbound <agentId> --number <id>` (or `--clear`)
58
+ *
59
+ * Own endpoint, and a single number rather than an array: the backend clears the
60
+ * number off any other agent holding it first (one inbound agent per number).
61
+ */
62
+ async function setWhatsappInboundCommand(agentId, opts) {
63
+ const api = (0, session_1.requireApi)();
64
+ if (!opts.clear && !opts.number)
65
+ return (0, ui_1.handleError)(new Error('--number <phoneNumberId> or --clear is required'));
66
+ try {
67
+ await api.setWhatsappInbound(agentId, opts.clear ? null : opts.number);
68
+ (0, ui_1.success)(opts.clear ? `Cleared WhatsApp inbound number on agent ${agentId}.` : `Set WhatsApp inbound number on agent ${agentId}.`);
69
+ }
70
+ catch (err) {
71
+ (0, ui_1.handleError)(err);
72
+ }
73
+ }
36
74
  /** `cku agents set-outbound <agentId> --number <id>` (or `--clear`) */
37
75
  async function setOutboundCommand(agentId, opts) {
38
76
  const api = (0, session_1.requireApi)();
@@ -234,6 +234,7 @@ async function agentsExportCommand(agentId, opts) {
234
234
  const versions = res.versions || [];
235
235
  if (!versions.length)
236
236
  return (0, ui_1.handleError)(new Error('This agent has no versions'));
237
+ (0, ui_1.needTty)('Pass --versions <ids> to choose which versions to export.');
237
238
  const picked = await (0, prompts_1.default)({
238
239
  type: 'multiselect',
239
240
  name: 'ids',
@@ -39,11 +39,14 @@ async function callsListCommand(opts) {
39
39
  const res = await api.listCalls({
40
40
  ...buildCallQuery(opts),
41
41
  pageSize: (0, ui_1.intFlag)(opts.limit, '--limit', 50),
42
- page: 1,
42
+ page: (0, ui_1.intFlag)(opts.page, '--page', 1),
43
43
  });
44
44
  const calls = res.calls || [];
45
- if (opts.json)
46
- return void console.log(JSON.stringify(calls, null, 2));
45
+ // --json stays a bare array so existing parsers keep working; --meta is the
46
+ // opt-in that also exposes pagination (totalDocs, next page) for scripts.
47
+ if (opts.json) {
48
+ return void console.log(JSON.stringify(opts.meta ? { calls, pagination: res.pagination ?? null } : calls, null, 2));
49
+ }
47
50
  if (!calls.length)
48
51
  return void (0, ui_1.info)('No calls found.');
49
52
  (0, ui_1.table)(['TYPE', 'FROM', 'TO', 'AGENT', 'VERSION', 'DURATION', 'HANGUP', 'TIME'], calls.map((c) => [
@@ -0,0 +1,48 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.dashboardApiCommand = dashboardApiCommand;
4
+ const dashboardApi_1 = require("../lib/dashboardApi");
5
+ const ui_1 = require("../lib/ui");
6
+ const jsonInput_1 = require("./jsonInput");
7
+ const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
8
+ /**
9
+ * `cku dashboard api <METHOD> <path> [--body <json|@file>] [--query k=v ...]`
10
+ *
11
+ * The escape hatch for a dashboard route the CLI has no command for yet. It goes
12
+ * through `dashboardApi().request`, so the session cookie stays inside the CLI
13
+ * (a script otherwise has to read it out of the account file) and the path rules
14
+ * there apply: it must start with "/", redirects are refused, and the host is
15
+ * always the configured backend. Prints the response body as JSON. On a non-2xx
16
+ * it prints the status and the whole error body to stderr and exits 1; it never
17
+ * prints a header, so a token cannot leak through it.
18
+ */
19
+ async function dashboardApiCommand(method, path, opts) {
20
+ try {
21
+ const verb = method.toUpperCase();
22
+ if (!METHODS.includes(verb))
23
+ throw new Error(`Method must be one of ${METHODS.join(', ')} (got "${method}").`);
24
+ let target = path;
25
+ if (opts.query?.length) {
26
+ const params = new URLSearchParams();
27
+ for (const kv of opts.query) {
28
+ const i = kv.indexOf('=');
29
+ if (i < 1)
30
+ throw new Error(`--query expects key=value (got "${kv}").`);
31
+ params.append(kv.slice(0, i), kv.slice(i + 1));
32
+ }
33
+ target += (path.includes('?') ? '&' : '?') + params.toString();
34
+ }
35
+ const body = opts.body ? await (0, jsonInput_1.readJsonFlag)(opts.body, '--body') : undefined;
36
+ const res = await (0, dashboardApi_1.dashboardApi)().request(verb, target, body);
37
+ console.log(JSON.stringify(res ?? null, null, 2));
38
+ }
39
+ catch (err) {
40
+ if (err instanceof dashboardApi_1.DashboardError) {
41
+ console.error(`HTTP ${err.status}`);
42
+ console.error(JSON.stringify(err.data ?? { message: err.message }, null, 2));
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ (0, ui_1.handleError)(err);
47
+ }
48
+ }
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.jsonHelpTip = jsonHelpTip;
4
4
  exports.resolveJsonInput = resolveJsonInput;
5
5
  exports.parseJsonInput = parseJsonInput;
6
+ exports.readJsonFlag = readJsonFlag;
6
7
  const fs_1 = require("fs");
7
8
  const looseJson_1 = require("../lib/looseJson");
8
9
  async function readStdin() {
@@ -54,3 +55,18 @@ async function resolveJsonInput(arg, file) {
54
55
  function parseJsonInput({ raw, fromShell }) {
55
56
  return fromShell ? (0, looseJson_1.parseLooseJson)(raw) : JSON.parse(raw);
56
57
  }
58
+ /**
59
+ * Read one JSON-valued flag (`--variables`, `--body`) given inline or as `@file`.
60
+ * Errors name the flag, so the message says which input was bad.
61
+ */
62
+ async function readJsonFlag(raw, flag) {
63
+ if (!raw.trim())
64
+ throw new Error(`${flag} needs JSON or @file.json.`);
65
+ const input = await resolveJsonInput(raw);
66
+ try {
67
+ return parseJsonInput(input);
68
+ }
69
+ catch (err) {
70
+ throw new Error(`${flag} is not valid JSON: ${err.message}${jsonHelpTip(input.raw, input.fromShell)}`);
71
+ }
72
+ }
@@ -596,9 +596,14 @@ async function kpiTargetCommand(opts) {
596
596
  try {
597
597
  current = (await api.kpiAgentConfig(agentId)) || {};
598
598
  }
599
- catch {
600
- // No config yet is a normal state, not a failure: the PUT creates it.
601
- current = {};
599
+ catch (err) {
600
+ // 404 is the only "no config yet" case, and it is a normal state — the PUT
601
+ // creates it. Anything else (401, 500, a network drop) is a real failure,
602
+ // and treating it as "no config" would send a PUT that clears the RCA
603
+ // prompt this read was supposed to preserve. That is silent data loss, so
604
+ // it has to stop here.
605
+ if (!(0, dashboardApi_1.isNotFound)(err))
606
+ throw err;
602
607
  }
603
608
  const metricName = 'connected_to_conversion_percentage';
604
609
  const body = {
@@ -1,7 +1,4 @@
1
1
  "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
2
  Object.defineProperty(exports, "__esModule", { value: true });
6
3
  exports.numbersListCommand = numbersListCommand;
7
4
  exports.numbersCatalogCommand = numbersCatalogCommand;
@@ -9,7 +6,6 @@ exports.numbersBuyCommand = numbersBuyCommand;
9
6
  exports.numbersSpamCommand = numbersSpamCommand;
10
7
  exports.numbersUnspamCommand = numbersUnspamCommand;
11
8
  exports.numbersReleaseCommand = numbersReleaseCommand;
12
- const prompts_1 = __importDefault(require("prompts"));
13
9
  const session_1 = require("../lib/session");
14
10
  const ui_1 = require("../lib/ui");
15
11
  function fmtDate(v) {
@@ -92,16 +88,8 @@ async function numbersBuyCommand(numberOrId, opts) {
92
88
  if (!number) {
93
89
  return (0, ui_1.handleError)(new Error('That number is not in the buyable pool (already taken or invalid id).'));
94
90
  }
95
- if (!opts.yes) {
96
- const { ok } = await (0, prompts_1.default)({
97
- type: 'confirm',
98
- name: 'ok',
99
- message: `Buy +${number.phoneNumber} for ₹${pricepernumber}? This deducts credits.`,
100
- initial: false,
101
- });
102
- if (!ok)
103
- return void (0, ui_1.info)('Cancelled.');
104
- }
91
+ if (!(await (0, ui_1.confirm)(`Buy +${number.phoneNumber} for ₹${pricepernumber}? This deducts credits.`, opts.yes)))
92
+ return;
105
93
  const res = await api.buyNumber(numberId, pricepernumber);
106
94
  return void (0, ui_1.success)(res.message || `Bought +${number.phoneNumber}.`);
107
95
  }
@@ -135,16 +123,8 @@ async function numbersReleaseCommand(numberOrId, opts) {
135
123
  const api = (0, session_1.requireApi)();
136
124
  try {
137
125
  const numberId = await resolveNumberId(api, numberOrId);
138
- if (!opts.yes) {
139
- const { ok } = await (0, prompts_1.default)({
140
- type: 'confirm',
141
- name: 'ok',
142
- message: 'Release (unrent) this number? It will be unassigned from all agents.',
143
- initial: false,
144
- });
145
- if (!ok)
146
- return void (0, ui_1.info)('Cancelled.');
147
- }
126
+ if (!(await (0, ui_1.confirm)('Release (unrent) this number? It will be unassigned from all agents.', opts.yes)))
127
+ return;
148
128
  const res = await api.releaseNumber(numberId);
149
129
  (0, ui_1.success)(res.message || 'Released.');
150
130
  }
@@ -35,6 +35,7 @@ async function readSecretValue(name, fromStdin) {
35
35
  throw new Error('--stdin requires piped or redirected input');
36
36
  return (await readStdin()).trim();
37
37
  }
38
+ (0, ui_1.needTty)('Pass --stdin and pipe the value in.');
38
39
  const answer = await (0, prompts_1.default)({ type: 'password', name: 'value', message: `Value for ${name}` });
39
40
  return typeof answer.value === 'string' ? answer.value.trim() : undefined;
40
41
  }
@@ -84,16 +85,8 @@ async function secretsRemoveCommand(inputName, opts) {
84
85
  const list = secrets || [];
85
86
  if (!list.some((secret) => secret.name === name))
86
87
  throw new Error(`Secret "${name}" not found`);
87
- if (!opts.yes) {
88
- const answer = await (0, prompts_1.default)({
89
- type: 'confirm',
90
- name: 'ok',
91
- message: `Remove secret "${name}"?`,
92
- initial: false,
93
- });
94
- if (!answer.ok)
95
- return void (0, ui_1.info)('Cancelled.');
96
- }
88
+ if (!(await (0, ui_1.confirm)(`Remove secret "${name}"?`, opts.yes)))
89
+ return;
97
90
  await api.saveSecrets(list.filter((secret) => secret.name !== name).map(preserve));
98
91
  (0, ui_1.success)(`Secret "${name}" removed.`);
99
92
  }
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.simVarsListCommand = simVarsListCommand;
4
+ exports.simVarsShowCommand = simVarsShowCommand;
5
+ exports.simVarsCreateCommand = simVarsCreateCommand;
6
+ exports.simVarsDeleteCommand = simVarsDeleteCommand;
7
+ const dashboardApi_1 = require("../lib/dashboardApi");
8
+ const ui_1 = require("../lib/ui");
9
+ const jsonInput_1 = require("./jsonInput");
10
+ const simulations_1 = require("./simulations");
11
+ /**
12
+ * Saved variable sets: named bundles of `testVariables` the dashboard's "Import
13
+ * Variables" button loads into a test case. Dashboard-only (cookie-authed), so
14
+ * these commands need `cku dashboard login`; `cku sim create --variables` needs
15
+ * no session and takes the same JSON.
16
+ */
17
+ async function simVarsListCommand(agentId, opts) {
18
+ try {
19
+ const res = await (0, dashboardApi_1.dashboardApi)().testVariableSets(agentId);
20
+ const sets = res?.data || [];
21
+ if (opts.json)
22
+ return void console.log(JSON.stringify(sets, null, 2));
23
+ if (!sets.length)
24
+ return void (0, ui_1.info)('No saved variable sets for this agent.');
25
+ (0, ui_1.table)(['NAME', 'ID', 'VARIABLES', 'CREATED'], sets.map((s) => [
26
+ s.name,
27
+ s._id,
28
+ Object.keys(s.callMetadata || {}).length,
29
+ s.createdAt ? new Date(s.createdAt).toLocaleString('en-US') : '',
30
+ ]));
31
+ }
32
+ catch (err) {
33
+ (0, ui_1.handleError)(err);
34
+ }
35
+ }
36
+ async function simVarsShowCommand(agentId, nameOrId, opts) {
37
+ try {
38
+ const set = await findSet(agentId, nameOrId);
39
+ if (opts.json)
40
+ return void console.log(JSON.stringify(set.callMetadata || {}, null, 2));
41
+ (0, ui_1.table)(['VARIABLE', 'VALUE'], Object.entries(set.callMetadata || {}).map(([k, v]) => [k, String(v)]));
42
+ }
43
+ catch (err) {
44
+ (0, ui_1.handleError)(err);
45
+ }
46
+ }
47
+ async function simVarsCreateCommand(agentId, name, opts) {
48
+ try {
49
+ if (!opts.variables) {
50
+ return (0, ui_1.handleError)(new Error('--variables <json|@file.json> is required, e.g. --variables @lead.json'));
51
+ }
52
+ const callMetadata = (0, simulations_1.toTestVariables)(await (0, jsonInput_1.readJsonFlag)(opts.variables, '--variables'));
53
+ const res = await (0, dashboardApi_1.dashboardApi)().createTestVariableSet({ agentId, name, callMetadata });
54
+ (0, ui_1.success)(`Saved variable set "${name}" (${res?.data?._id || ''}) with ${Object.keys(callMetadata).length} variables.`);
55
+ }
56
+ catch (err) {
57
+ (0, ui_1.handleError)(err);
58
+ }
59
+ }
60
+ async function simVarsDeleteCommand(agentId, nameOrId, opts) {
61
+ try {
62
+ const set = await findSet(agentId, nameOrId);
63
+ if (!(await (0, ui_1.confirm)(`Delete saved variable set "${set.name}"?`, opts.yes)))
64
+ return;
65
+ await (0, dashboardApi_1.dashboardApi)().deleteTestVariableSet(set._id);
66
+ (0, ui_1.success)(`Deleted "${set.name}".`);
67
+ }
68
+ catch (err) {
69
+ (0, ui_1.handleError)(err);
70
+ }
71
+ }
72
+ /** Match by exact id, then by exact name; an ambiguous name is an error, not a guess. */
73
+ async function findSet(agentId, nameOrId) {
74
+ const res = await (0, dashboardApi_1.dashboardApi)().testVariableSets(agentId);
75
+ const sets = res?.data || [];
76
+ const byId = sets.find((s) => s._id === nameOrId);
77
+ if (byId)
78
+ return byId;
79
+ const byName = sets.filter((s) => s.name === nameOrId);
80
+ if (byName.length === 1)
81
+ return byName[0];
82
+ if (byName.length > 1) {
83
+ throw new Error(`${byName.length} saved sets are named "${nameOrId}". Use an id: ${byName.map((s) => s._id).join(', ')}`);
84
+ }
85
+ throw new Error(`No saved variable set "${nameOrId}" for this agent. List them: cku sim variables list ${agentId}`);
86
+ }
@@ -3,15 +3,47 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.sleep = void 0;
7
+ exports.toTestVariables = toTestVariables;
8
+ exports.toTestFunctions = toTestFunctions;
6
9
  exports.simTestsListCommand = simTestsListCommand;
7
10
  exports.simCreateCommand = simCreateCommand;
8
11
  exports.simDeleteCommand = simDeleteCommand;
12
+ exports.waitForBatch = waitForBatch;
9
13
  exports.simRunCommand = simRunCommand;
10
14
  exports.simRunsListCommand = simRunsListCommand;
11
15
  exports.simResultsCommand = simResultsCommand;
12
16
  const prompts_1 = __importDefault(require("prompts"));
13
17
  const session_1 = require("../lib/session");
14
18
  const ui_1 = require("../lib/ui");
19
+ const jsonInput_1 = require("./jsonInput");
20
+ /**
21
+ * `testVariables` is a flat name -> string map on the backend (ids, "True", and
22
+ * JSON-encoded arrays are all strings). Accept natural JSON and stringify what is
23
+ * not already a string, so `{"lead_id": 900029, "pointers": ["a"]}` works.
24
+ */
25
+ function toTestVariables(input) {
26
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
27
+ throw new Error('--variables must be a JSON object of name -> value, e.g. {"lead_id":"900029"}.');
28
+ }
29
+ const out = {};
30
+ for (const [k, v] of Object.entries(input)) {
31
+ out[k] = typeof v === 'string' ? v : v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
32
+ }
33
+ return out;
34
+ }
35
+ /** `testFunctions` is a list of `{name, response}` mocks; `response` is an object or a string. */
36
+ function toTestFunctions(input) {
37
+ if (!Array.isArray(input)) {
38
+ throw new Error('--functions must be a JSON array of {"name": ..., "response": ...} mocks.');
39
+ }
40
+ return input.map((f, i) => {
41
+ if (!f || typeof f !== 'object' || typeof f.name !== 'string' || !f.name || !('response' in f)) {
42
+ throw new Error(`--functions[${i}] needs a "name" (string) and a "response" (object or string).`);
43
+ }
44
+ return { name: f.name, response: f.response };
45
+ });
46
+ }
15
47
  async function simTestsListCommand(agentId, opts) {
16
48
  const api = (0, session_1.requireApi)();
17
49
  try {
@@ -30,8 +62,12 @@ async function simTestsListCommand(agentId, opts) {
30
62
  async function simCreateCommand(agentId, opts) {
31
63
  const api = (0, session_1.requireApi)();
32
64
  try {
65
+ // Parse the JSON flags before prompting, so a typo fails before any typing.
66
+ const testVariables = opts.variables ? toTestVariables(await (0, jsonInput_1.readJsonFlag)(opts.variables, '--variables')) : {};
67
+ const testFunctions = opts.functions ? toTestFunctions(await (0, jsonInput_1.readJsonFlag)(opts.functions, '--functions')) : [];
33
68
  let { name, prompt, criteria } = opts;
34
69
  if (!name || !prompt || !criteria) {
70
+ (0, ui_1.needTty)('Pass --name, --prompt and --criteria.');
35
71
  const answers = await (0, prompts_1.default)([
36
72
  { type: name ? null : 'text', name: 'name', message: 'Test case name' },
37
73
  { type: prompt ? null : 'text', name: 'prompt', message: 'Simulated user prompt' },
@@ -50,8 +86,8 @@ async function simCreateCommand(agentId, opts) {
50
86
  userPrompt: prompt,
51
87
  successCriteria: criteria,
52
88
  testLLMModel: opts.model || 'gpt-4.1-mini',
53
- testVariables: {},
54
- testFunctions: [],
89
+ testVariables,
90
+ testFunctions,
55
91
  });
56
92
  (0, ui_1.success)(`Created test case ${res.testCase?._id || ''}.`);
57
93
  }
@@ -62,13 +98,8 @@ async function simCreateCommand(agentId, opts) {
62
98
  async function simDeleteCommand(testCaseId, opts) {
63
99
  const api = (0, session_1.requireApi)();
64
100
  try {
65
- if (!opts.yes) {
66
- const { ok } = await (0, prompts_1.default)({
67
- type: 'confirm', name: 'ok', message: `Delete test case ${testCaseId}?`, initial: false,
68
- });
69
- if (!ok)
70
- return void (0, ui_1.info)('Cancelled.');
71
- }
101
+ if (!(await (0, ui_1.confirm)(`Delete test case ${testCaseId}?`, opts.yes)))
102
+ return;
72
103
  const res = await api.deleteTestCase(testCaseId);
73
104
  (0, ui_1.success)(res.msg || 'Deleted.');
74
105
  }
@@ -76,6 +107,48 @@ async function simDeleteCommand(testCaseId, opts) {
76
107
  (0, ui_1.handleError)(err);
77
108
  }
78
109
  }
110
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
111
+ exports.sleep = sleep;
112
+ /**
113
+ * Poll a batch until it has produced every row it will produce.
114
+ *
115
+ * The backend has no "finished" flag. A batch carries `totalRuns` and the
116
+ * dashboard counts rows in the results list against it, so that is the done
117
+ * signal here too. Progress goes to stderr, so `--json` output stays parseable.
118
+ * Returns the last rows seen and whether the batch completed before the timeout.
119
+ */
120
+ async function waitForBatch(api, batchId, agentId, opts) {
121
+ const timeoutMs = (0, ui_1.intFlag)(opts.timeout, '--timeout', 600) * 1000;
122
+ const every = opts.intervalMs ?? 5000;
123
+ const deadline = Date.now() + timeoutMs;
124
+ let rows = [];
125
+ let total;
126
+ for (;;) {
127
+ const res = await api.simulationResults(batchId);
128
+ rows = res.simulations || res.results || res.data || [];
129
+ if (total === undefined && agentId) {
130
+ const runs = (await api.listSimulationRuns(agentId)).batches || [];
131
+ total = runs.find((b) => b._id === batchId)?.totalRuns;
132
+ }
133
+ console.error(` ${rows.length}${total ? `/${total}` : ''} results`);
134
+ if (total && rows.length >= total)
135
+ return { rows, done: true, total };
136
+ if (Date.now() + every > deadline)
137
+ return { rows, done: false, total };
138
+ await (0, exports.sleep)(every);
139
+ }
140
+ }
141
+ function resultTable(rows) {
142
+ (0, ui_1.table)(['TEST CASE', 'VERSION', 'RESULT', 'TURNS', 'SUMMARY'], rows.map((r) => [
143
+ r.testcaseId?.testcaseName || r.testcaseName || r.testcaseId || '',
144
+ r.versionId?.versionName || r.versionId || '',
145
+ r.isTestSucceed === undefined ? '' : r.isTestSucceed ? 'PASS' : 'FAIL',
146
+ r.total_turns ?? '',
147
+ String(r.testResultSummary || '').replace(/\s+/g, ' ').slice(0, 70),
148
+ ]));
149
+ const passed = rows.filter((r) => r.isTestSucceed).length;
150
+ (0, ui_1.info)(`\n${passed}/${rows.length} passed.`);
151
+ }
79
152
  async function simRunCommand(agentId, opts) {
80
153
  const api = (0, session_1.requireApi)();
81
154
  const testIds = (opts.tests || '').split(',').map((s) => s.trim()).filter(Boolean);
@@ -85,13 +158,31 @@ async function simRunCommand(agentId, opts) {
85
158
  if (!versionIds.length)
86
159
  return (0, ui_1.handleError)(new Error('--versions <versionIds> is required'));
87
160
  try {
161
+ const epoch = (0, ui_1.intFlag)(opts.epoch, '--epoch', 1);
162
+ (0, ui_1.intFlag)(opts.timeout, '--timeout', 600); // reject a bad value before starting a run
88
163
  const res = await api.runSimulation({
89
164
  agentId, testIds, versionIds,
90
165
  batchName: opts.name || `cli-run-${Date.now()}`,
91
- epoch: 1,
166
+ epoch,
92
167
  });
93
- (0, ui_1.success)(`Simulation run started (${res.batch?._id || ''}). Results arrive asynchronously.`);
94
- (0, ui_1.info)(`Check with: cku sim results ${res.batch?._id || '<batchId>'}`);
168
+ const batchId = res.batch?._id || '';
169
+ if (!opts.wait) {
170
+ (0, ui_1.success)(`Simulation run started (${batchId}). Results arrive asynchronously.`);
171
+ return void (0, ui_1.info)(`Check with: cku sim results ${batchId || '<batchId>'} (or re-run with --wait)`);
172
+ }
173
+ if (!batchId)
174
+ return (0, ui_1.handleError)(new Error('The backend did not return a batch id, so there is nothing to wait on.'));
175
+ console.error(`Run started (${batchId}); waiting for ${testIds.length * versionIds.length * epoch} results …`);
176
+ const { rows, done, total } = await waitForBatch(api, batchId, agentId, opts);
177
+ if (opts.json)
178
+ console.log(JSON.stringify(rows, null, 2));
179
+ else
180
+ resultTable(rows);
181
+ if (!done) {
182
+ 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`));
183
+ }
184
+ if (rows.some((r) => !r.isTestSucceed))
185
+ process.exitCode = 1;
95
186
  }
96
187
  catch (err) {
97
188
  (0, ui_1.handleError)(err);
@@ -101,15 +192,16 @@ async function simRunsListCommand(agentId, opts) {
101
192
  const api = (0, session_1.requireApi)();
102
193
  try {
103
194
  const res = await api.listSimulationRuns(agentId);
104
- const rows = res.runs || res.data || [];
195
+ const rows = res.batches || res.runs || res.data || [];
105
196
  if (opts.json)
106
197
  return void console.log(JSON.stringify(rows, null, 2));
107
198
  if (!rows.length)
108
199
  return void (0, ui_1.info)('No simulation runs yet.');
109
- (0, ui_1.table)(['NAME', 'BATCH ID', 'STATUS', 'CREATED'], rows.map((r) => [
200
+ (0, ui_1.table)(['NAME', 'BATCH ID', 'RUNS', 'EPOCH', 'CREATED'], rows.map((r) => [
110
201
  r.batchName || r.name || '',
111
- r.batch?._id || r._id || '',
112
- r.status || '',
202
+ r._id || r.batch?._id || '',
203
+ r.totalRuns ?? '',
204
+ r.epoch ?? '',
113
205
  r.createdAt ? new Date(r.createdAt).toLocaleString('en-US') : '',
114
206
  ]));
115
207
  }
@@ -120,18 +212,24 @@ async function simRunsListCommand(agentId, opts) {
120
212
  async function simResultsCommand(batchId, opts) {
121
213
  const api = (0, session_1.requireApi)();
122
214
  try {
123
- const res = await api.simulationResults(batchId);
124
- const rows = res.results || res.data || [];
215
+ let rows;
216
+ if (opts.wait) {
217
+ if (!opts.agent)
218
+ return (0, ui_1.handleError)(new Error('--wait needs --agent <agentId> to know how many results to expect.'));
219
+ const out = await waitForBatch(api, batchId, opts.agent, opts);
220
+ rows = out.rows;
221
+ if (!out.done)
222
+ process.exitCode = 1;
223
+ }
224
+ else {
225
+ const res = await api.simulationResults(batchId);
226
+ rows = res.simulations || res.results || res.data || [];
227
+ }
125
228
  if (opts.json)
126
229
  return void console.log(JSON.stringify(rows, null, 2));
127
230
  if (!rows.length)
128
- return void (0, ui_1.info)('No results yet — runs are asynchronous.');
129
- (0, ui_1.table)(['TEST CASE', 'VERSION', 'PASSED', 'SCORE'], rows.map((r) => [
130
- r.testcaseName || r.testCaseId || '',
131
- r.versionId?.versionName || r.versionId || '',
132
- r.passed === undefined ? '' : r.passed ? 'yes' : 'no',
133
- r.score ?? '',
134
- ]));
231
+ return void (0, ui_1.info)('No results yet — runs are asynchronous. Use --wait --agent <agentId> to wait.');
232
+ resultTable(rows);
135
233
  }
136
234
  catch (err) {
137
235
  (0, ui_1.handleError)(err);
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.whatsappListCommand = whatsappListCommand;
4
+ const session_1 = require("../lib/session");
5
+ const ui_1 = require("../lib/ui");
6
+ /**
7
+ * `cku whatsapp list`
8
+ *
9
+ * Numbers come from Meta (the linked WhatsApp Business Account), not a platform
10
+ * pool, so there is no catalog/buy/register here. Adding one is Meta's embedded
11
+ * signup (Facebook login) behind the dashboard's "+ Add Phone Number" button, an
12
+ * interactive OAuth flow the CLI cannot drive.
13
+ */
14
+ async function whatsappListCommand(opts) {
15
+ const api = (0, session_1.requireApi)();
16
+ try {
17
+ const { phoneNumbers } = await api.listWhatsappNumbers();
18
+ const list = phoneNumbers || [];
19
+ if (opts.json)
20
+ return void console.log(JSON.stringify(list, null, 2));
21
+ if (!list.length) {
22
+ return void console.log('No WhatsApp numbers linked yet — add one on the dashboard first ("+ Add Phone Number").');
23
+ }
24
+ (0, ui_1.table)(['NUMBER', 'ID', 'VERIFIED NAME', 'STATUS', 'QUALITY'], list.map((n) => [
25
+ n.display_phone_number,
26
+ n.id,
27
+ n.verified_name || '',
28
+ n.code_verification_status || '',
29
+ n.quality_rating || '',
30
+ ]));
31
+ }
32
+ catch (err) {
33
+ (0, ui_1.handleError)(err);
34
+ }
35
+ }
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ const models_1 = require("./commands/models");
17
17
  const agents_1 = require("./commands/agents");
18
18
  const agentVersions_1 = require("./commands/agentVersions");
19
19
  const agentPhone_1 = require("./commands/agentPhone");
20
+ const whatsapp_1 = require("./commands/whatsapp");
20
21
  const numbers_1 = require("./commands/numbers");
21
22
  const calls_1 = require("./commands/calls");
22
23
  const ongoing_1 = require("./commands/ongoing");
@@ -28,6 +29,8 @@ const update_1 = require("./commands/update");
28
29
  const analytics_1 = require("./commands/analytics");
29
30
  const batches_1 = require("./commands/batches");
30
31
  const simulations_1 = require("./commands/simulations");
32
+ const simVariables_1 = require("./commands/simVariables");
33
+ const dashboardApiCmd_1 = require("./commands/dashboardApiCmd");
31
34
  const auditStrategies_1 = require("./commands/auditStrategies");
32
35
  const chatAgents_1 = require("./commands/chatAgents");
33
36
  // The command this fork installs as. Deliberately NOT `ck` or `callkaro`:
@@ -78,6 +81,7 @@ dashboard
78
81
  .command('login')
79
82
  .description('sign in with email + password, exactly as the login form does')
80
83
  .option('--email <email>', 'skip the email prompt')
84
+ .option('--no-save-password', "don't keep the password (the session then lapses after 24h)")
81
85
  .action(dashboardAuth_1.dashboardLoginCommand);
82
86
  dashboard
83
87
  .command('whoami')
@@ -93,6 +97,13 @@ dashboard
93
97
  .description('forget the dashboard session (the ck_ token is kept)')
94
98
  .option('--yes', 'skip the confirmation')
95
99
  .action(dashboardAuth_1.dashboardLogoutCommand);
100
+ dashboard
101
+ .command('api <method> <path>')
102
+ .description('call any dashboard route with the stored session (the token never leaves the CLI)')
103
+ .option('--body <json|@file>', 'JSON request body')
104
+ .option('--query <k=v...>', 'query parameter, repeatable', (v, prev = []) => [...prev, v])
105
+ .addHelpText('after', '\n Example: cku dashboard api GET /v1/test-simulations --query agentId=<id>\n Errors print the status and full body to stderr and exit 1.')
106
+ .action(dashboardApiCmd_1.dashboardApiCommand);
96
107
  // Which models the server accepts. Same source the validator uses, so it cannot drift.
97
108
  program
98
109
  .command('models')
@@ -325,6 +336,14 @@ agents.command('set-outbound <agentId>').description("set/clear an agent's outbo
325
336
  .option('--number <id>', 'phone number id')
326
337
  .option('--clear', 'clear the outbound number')
327
338
  .action(agentPhone_1.setOutboundCommand);
339
+ agents.command('set-whatsapp-inbound <agentId>').description("set/clear an agent's WhatsApp inbound number")
340
+ .option('--number <id>', 'WhatsApp phone number id (see `cku whatsapp list`)')
341
+ .option('--clear', 'clear the inbound number')
342
+ .action(agentPhone_1.setWhatsappInboundCommand);
343
+ agents.command('set-whatsapp-outbound <agentId>').description("set/clear an agent's WhatsApp outbound number")
344
+ .option('--number <id>', 'WhatsApp phone number id (see `cku whatsapp list`)')
345
+ .option('--clear', 'clear the outbound number')
346
+ .action(agentPhone_1.setWhatsappOutboundCommand);
328
347
  // Version management: clone, publish, activate, A/B testing
329
348
  agents.command('clone-version <agentId>').description('clone a version with a required target prompt mode and language')
330
349
  .option('--versions <id>', 'source version id')
@@ -419,6 +438,13 @@ addSkillsProviderOptions(skills.command('dir'))
419
438
  .description('print the selected install location(s)')
420
439
  .option('--project', 'print project location(s)')
421
440
  .action(skills_1.skillsDirCommand);
441
+ // --- whatsapp --------------------------------------------------------------
442
+ // Numbers come from your linked Meta WhatsApp Business Account, not a platform
443
+ // pool: add one on the dashboard ("+ Add Phone Number", a Facebook-login flow the
444
+ // CLI cannot run), then list/assign it here. No catalog/buy/register.
445
+ const whatsapp = program.command('whatsapp').description('manage WhatsApp numbers (from your linked Meta WhatsApp Business Account)');
446
+ whatsapp.command('list').description('list WhatsApp numbers on your linked WhatsApp Business Account')
447
+ .option('--json', 'raw JSON').action(whatsapp_1.whatsappListCommand);
422
448
  // --- numbers ---------------------------------------------------------------
423
449
  const numbers = program.command('numbers').description('manage phone numbers');
424
450
  numbers.command('list').description('list your phone numbers').option('--json', 'raw JSON').action(numbers_1.numbersListCommand);
@@ -506,7 +532,9 @@ calls.command('make').description('place a single outbound call (real, or --test
506
532
  .action(calls_1.callsMakeCommand);
507
533
  addCallFilters(calls.command('list').description('list call history'))
508
534
  .option('--limit <n>', 'page size (default 50)')
509
- .option('--json', 'raw JSON')
535
+ .option('--page <n>', 'page number (default 1)')
536
+ .option('--json', 'raw JSON (a bare array of calls)')
537
+ .option('--meta', 'with --json, wrap as {calls, pagination} so scripts can see totalDocs')
510
538
  .action(calls_1.callsListCommand);
511
539
  calls.command('get <callId>').description('show one call (with recording URL)')
512
540
  .option('--json', 'raw JSON')
@@ -601,6 +629,8 @@ sim.command('create <agentId>').description('create a test case')
601
629
  .option('--prompt <text>', 'simulated user prompt')
602
630
  .option('--criteria <text>', 'success criteria')
603
631
  .option('--model <model>', 'test LLM model')
632
+ .option('--variables <json|@file>', 'testVariables: a JSON object of name -> value (non-strings are stringified)')
633
+ .option('--functions <json|@file>', 'function mocks: a JSON array of {"name": ..., "response": ...}')
604
634
  .action(simulations_1.simCreateCommand);
605
635
  sim.command('delete <testCaseId>').description('delete a test case').option('--yes', 'skip confirmation')
606
636
  .action(simulations_1.simDeleteCommand);
@@ -608,10 +638,28 @@ sim.command('run <agentId>').description('run a batch simulation (async)')
608
638
  .option('--tests <ids>', 'comma-separated test case ids')
609
639
  .option('--versions <ids>', 'comma-separated version ids')
610
640
  .option('--name <name>', 'run name')
641
+ .option('--epoch <n>', 'times to run each test case (default 1)')
642
+ .option('--wait', 'block until every result is in; exit 1 if any test fails')
643
+ .option('--timeout <seconds>', 'with --wait, give up after this long (default 600)')
644
+ .option('--json', 'with --wait, print the results as a bare JSON array')
611
645
  .action(simulations_1.simRunCommand);
612
646
  sim.command('runs <agentId>').description('list simulation runs').option('--json', 'raw JSON').action(simulations_1.simRunsListCommand);
613
647
  sim.command('results <batchId>').description('results of a simulation run').option('--json', 'raw JSON')
648
+ .option('--wait', 'block until the batch has all its results (needs --agent)')
649
+ .option('--agent <agentId>', 'with --wait, the agent the batch belongs to')
650
+ .option('--timeout <seconds>', 'with --wait, give up after this long (default 600)')
614
651
  .action(simulations_1.simResultsCommand);
652
+ // Saved variable sets are dashboard-only (cookie-authed): they need `cku dashboard login`.
653
+ const simVars = sim.command('variables').description('saved variable sets for test cases (needs `cku dashboard login`)');
654
+ simVars.command('list <agentId>').description('list saved variable sets').option('--json', 'raw JSON')
655
+ .action(simVariables_1.simVarsListCommand);
656
+ simVars.command('show <agentId> <nameOrId>').description('show one saved set').option('--json', 'bare variables object')
657
+ .action(simVariables_1.simVarsShowCommand);
658
+ simVars.command('create <agentId> <name>').description('save a variable set')
659
+ .option('--variables <json|@file>', 'a JSON object of name -> value')
660
+ .action(simVariables_1.simVarsCreateCommand);
661
+ simVars.command('delete <agentId> <nameOrId>').description('delete a saved set').option('--yes', 'skip confirmation')
662
+ .action(simVariables_1.simVarsDeleteCommand);
615
663
  // --- audit strategies ------------------------------------------------------
616
664
  const audit = program.command('audit-strategies').alias('audit').description('AI Auditor audit strategies');
617
665
  audit.command('list <agentId>').description('list audit strategies for an agent').option('--json', 'raw JSON')
package/dist/lib/api.js CHANGED
@@ -144,6 +144,14 @@ class Api {
144
144
  publishChatAgentVersion(agentId, versionId) {
145
145
  return this.request('POST', `/cli/chat-agents/${agentId}/publish`, { versionId });
146
146
  }
147
+ // --- whatsapp numbers -----------------------------------------------------
148
+ // Numbers come from Meta (the linked WhatsApp Business Account), not a
149
+ // platform pool, so there is no buy/catalog counterpart to /cli/numbers.
150
+ listWhatsappNumbers() { return this.request('GET', '/cli/whatsapp'); }
151
+ /** Pass null to clear. The backend first unassigns the number from any other agent. */
152
+ setWhatsappInbound(agentId, whatsappInboundPhoneNumber) {
153
+ return this.request('POST', '/cli/agents/whatsapp-inbound', { agentId, whatsappInboundPhoneNumber });
154
+ }
147
155
  // --- phone numbers --------------------------------------------------------
148
156
  listNumbers() { return this.request('GET', '/cli/numbers'); }
149
157
  numbersCatalog() { return this.request('GET', '/cli/numbers/catalog'); }
@@ -249,9 +249,8 @@ function saveAccount(email, session) {
249
249
  function saveDashboardSession(email, dashboard) {
250
250
  const slug = slugFor(email);
251
251
  const current = loadAccount(slug);
252
- // Built field by field rather than spread. A caller that accidentally passes a
253
- // password (or anything else) must not have it written to disk — the guarantee
254
- // that no password is ever persisted has to hold here, not at the call site.
252
+ // Built field by field rather than spread, so stray fields a caller passes are
253
+ // not written to disk. `password` is the one deliberate exception.
255
254
  const stored = {
256
255
  email: dashboard.email || email,
257
256
  token: dashboard.token,
@@ -259,6 +258,7 @@ function saveDashboardSession(email, dashboard) {
259
258
  ...(dashboard.sessionToken ? { sessionToken: dashboard.sessionToken } : {}),
260
259
  ...(dashboard.userId ? { userId: dashboard.userId } : {}),
261
260
  ...(dashboard.expiresAt ? { expiresAt: dashboard.expiresAt } : {}),
261
+ ...(dashboard.password ? { password: dashboard.password } : {}),
262
262
  };
263
263
  writeJson(accountPath(slug), { ...current, email, dashboard: stored, savedAt: new Date().toISOString() });
264
264
  return slug;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DashboardApi = void 0;
3
+ exports.DashboardApi = exports.DashboardError = void 0;
4
+ exports.isNotFound = isNotFound;
4
5
  exports.dashboardApi = dashboardApi;
5
6
  const config_1 = require("./config");
6
7
  const dashboardAuth_1 = require("./dashboardAuth");
@@ -27,6 +28,29 @@ const dashboardAuth_1 = require("./dashboardAuth");
27
28
  *
28
29
  * Everything on this surface is undocumented and can change without notice.
29
30
  */
31
+ /**
32
+ * A non-2xx response from the dashboard API.
33
+ *
34
+ * Deliberately shaped like `ApiError` on the `/cli/*` side, so a caller reads
35
+ * `err.status` the same way on both surfaces. Extends Error so existing
36
+ * `handleError` paths and message-based assertions keep working unchanged.
37
+ */
38
+ class DashboardError extends Error {
39
+ status;
40
+ data;
41
+ /** `data` is the parsed error body, kept whole for callers (`dashboard api`) that print it. */
42
+ constructor(status, message, data) {
43
+ super(message);
44
+ this.status = status;
45
+ this.data = data;
46
+ this.name = 'DashboardError';
47
+ }
48
+ }
49
+ exports.DashboardError = DashboardError;
50
+ /** True when the failure was "no such record", as opposed to a real error. */
51
+ function isNotFound(err) {
52
+ return err instanceof DashboardError && err.status === 404;
53
+ }
30
54
  class DashboardApi {
31
55
  baseUrl;
32
56
  constructor(baseUrl) {
@@ -38,7 +62,12 @@ class DashboardApi {
38
62
  * stored 24h token is still valid, and a long-lived client must not outlive it.
39
63
  */
40
64
  async request(method, path, body) {
41
- const { token, sessionToken } = (0, dashboardAuth_1.requireDashboard)();
65
+ // baseUrl + path is the whole URL, so a path that does not start with "/" can
66
+ // rewrite the host ("https://backend.callkaro.ai" + "@evil.com/x") and carry
67
+ // the session cookie with it.
68
+ if (!path.startsWith('/'))
69
+ throw new Error(`Dashboard path must start with "/" (got "${path}").`);
70
+ const { token, sessionToken } = await (0, dashboardAuth_1.requireDashboard)();
42
71
  const headers = { 'Content-Type': 'application/json' };
43
72
  if (usesCookieAuth(path)) {
44
73
  headers.Cookie = `access_token=${sessionToken}`;
@@ -52,6 +81,9 @@ class DashboardApi {
52
81
  method,
53
82
  headers,
54
83
  body: body === undefined ? undefined : JSON.stringify(body),
84
+ // A redirect would re-send the cookie to wherever it points.
85
+ redirect: 'error',
86
+ signal: AbortSignal.timeout(30_000),
55
87
  });
56
88
  }
57
89
  catch (err) {
@@ -66,7 +98,12 @@ class DashboardApi {
66
98
  data = { msg: text.slice(0, 200) };
67
99
  }
68
100
  if (!res.ok) {
69
- throw new Error(data?.msg || data?.message || `Request failed (${res.status})`);
101
+ // Carry the status, not just the message. A caller that cannot tell 404
102
+ // from 500 has to treat every failure the same, and for a read-modify-write
103
+ // that is the difference between "no config yet" and "the read failed" —
104
+ // collapsing them sends a write that discards what it failed to read.
105
+ // Mirrors ApiError on the /cli/* side.
106
+ throw new DashboardError(res.status, data?.msg || data?.message || `Request failed (${res.status})`, data);
70
107
  }
71
108
  return data;
72
109
  }
@@ -96,6 +133,17 @@ class DashboardApi {
96
133
  return this.request('GET', `/pending-tasks${qs(query)}`);
97
134
  }
98
135
  teamMembers() { return this.request('GET', '/feedback/users/team-members'); }
136
+ // --- saved simulation variable sets ----------------------------------------
137
+ /** `agentId` is required; without it the backend answers 400 "Invalid agentId". */
138
+ testVariableSets(agentId) {
139
+ return this.request('GET', `/v1/call-metadata-test-variables${qs({ agentId })}`);
140
+ }
141
+ createTestVariableSet(body) {
142
+ return this.request('POST', '/v1/call-metadata-test-variables', body);
143
+ }
144
+ deleteTestVariableSet(id) {
145
+ return this.request('DELETE', `/v1/call-metadata-test-variables/${encodeURIComponent(id)}`);
146
+ }
99
147
  // --- KPI board writes -----------------------------------------------------
100
148
  //
101
149
  // Routes, bodies and the column set below were read out of the dashboard's own
@@ -60,7 +60,7 @@ function msUntilExpiry(token) {
60
60
  }
61
61
  /**
62
62
  * Password sign-in against the dashboard, exactly as the web form does it.
63
- * The password is used here and then discarded — it is never stored.
63
+ * Returns the session without the password; the caller decides whether to keep it.
64
64
  *
65
65
  * Two JWTs come back and both are kept, because they authorise different routes:
66
66
  * `auth-token` in the body serves `GET /v2/agents/` as a bearer, while
@@ -133,34 +133,44 @@ function forgetDashboard(email) {
133
133
  * Returns BOTH tokens, because the two halves of the dashboard need different
134
134
  * ones. `dashboardApi.ts` picks per route; callers should not choose.
135
135
  *
136
- * There is nothing to refresh — the tokens last 24h and the backend has no
137
- * refresh endpoint (confirmed by capture). So this checks expiry locally and
138
- * fails with the concrete next command, rather than pretending it can renew.
136
+ * The backend has no refresh endpoint (confirmed by capture), so a lapsed
137
+ * session is renewed by logging in again. With a stored password that happens
138
+ * here, silently; without one this fails with the concrete next command.
139
139
  */
140
- function requireDashboard() {
140
+ async function requireDashboard() {
141
141
  const slug = (0, config_1.activeAccount)();
142
142
  const email = slug ? (0, config_1.loadAccount)(slug).email : undefined;
143
- const session = dashboardSession(email);
143
+ let session = dashboardSession(email);
144
144
  if (!session?.token) {
145
145
  throw new Error(`No dashboard session for ${email || 'the account in effect'}.\n` +
146
146
  ' Dashboard endpoints (/auth/*, /v1/*, /kpi-manager, /pending-tasks) need a dashboard login, not a ck_ token.\n' +
147
147
  ' Run: cku dashboard login');
148
148
  }
149
- if (!session.sessionToken) {
150
- // A session stored before the two-token split. Re-issuing is the only fix;
151
- // the cookie token cannot be derived from the body token.
152
- throw new Error(`The stored dashboard session for ${email} is incomplete — it predates the two-token fix.\n` +
153
- ' /auth/login returns a separate access_token cookie that the dashboard routes require.\n' +
154
- ' Run: cku dashboard login');
155
- }
156
149
  // Expiry is read off the cookie token: it is the one that guards every route
157
150
  // except /v2/agents/, so it is the one that matters for a session's lifetime.
158
- const left = msUntilExpiry(session.sessionToken);
159
- if (left !== undefined && left <= 0) {
160
- const when = new Date(Date.now() + left).toLocaleString('en-US');
161
- throw new Error(`Dashboard session for ${email} expired ${when}.\n` +
162
- ' There is no refresh endpoint — the token lives 24h and must be re-issued.\n' +
163
- ' Run: cku dashboard login');
151
+ // A session stored before the two-token split has no cookie token, and that
152
+ // cannot be derived from the body token, so it needs a fresh login too.
153
+ // With a stored password, renew a minute early so the token cannot lapse
154
+ // mid-request; without one a token that still has time left is still valid.
155
+ const left = session.sessionToken ? msUntilExpiry(session.sessionToken) : 0;
156
+ if (left !== undefined && left <= (session.password ? 60_000 : 0)) {
157
+ if (!session.password) {
158
+ const why = session.sessionToken
159
+ ? `Dashboard session for ${email} expired ${new Date(Date.now() + left).toLocaleString('en-US')}.\n` +
160
+ ' There is no refresh endpoint — the token lives 24h and must be re-issued.'
161
+ : `The stored dashboard session for ${email} is incomplete — it predates the two-token fix.\n` +
162
+ ' /auth/login returns a separate access_token cookie that the dashboard routes require.';
163
+ throw new Error(`${why}\n Run: cku dashboard login`);
164
+ }
165
+ try {
166
+ const renewed = await dashboardLogin(session.email || email, session.password);
167
+ saveDashboard(email, { ...renewed, password: session.password });
168
+ session = renewed;
169
+ }
170
+ catch (err) {
171
+ throw new Error(`Dashboard session for ${email} expired and re-login with the stored password failed: ${err.message}\n` +
172
+ ' If the password changed, run: cku dashboard login');
173
+ }
164
174
  }
165
175
  return { token: session.token, sessionToken: session.sessionToken, email: session.email };
166
176
  }
@@ -182,6 +192,7 @@ function expiryLabel(token) {
182
192
  /** `cku dashboard login` — the browser's email+password flow, minus the browser. */
183
193
  async function dashboardLoginCommand(opts) {
184
194
  try {
195
+ (0, ui_1.needTty)('Dashboard login is interactive; sign in from a terminal.');
185
196
  const answers = await (0, prompts_1.default)([
186
197
  { type: opts.email ? null : 'text', name: 'email', message: 'Email' },
187
198
  { type: 'password', name: 'password', message: 'Password' },
@@ -191,10 +202,18 @@ async function dashboardLoginCommand(opts) {
191
202
  if (!email || !password)
192
203
  return (0, ui_1.handleError)(new Error('Email and password are required'));
193
204
  const session = await dashboardLogin(email, password);
194
- saveDashboard(email, session);
205
+ // Kept by default so the session renews itself every 24h; --no-save-password
206
+ // opts out and brings back a daily re-login.
207
+ const keep = opts.savePassword !== false;
208
+ saveDashboard(email, keep ? { ...session, password } : session);
195
209
  (0, ui_1.success)(`Dashboard session stored for ${session.email} (expires in ${expiryLabel(session.token)}).`);
196
- (0, ui_1.info)(' Your password was not stored. The token cannot be refreshed, so you re-run');
197
- (0, ui_1.info)(' this command once it expires.');
210
+ if (keep) {
211
+ (0, ui_1.info)(' Password saved (plain text, mode 0600) so the session renews itself when it lapses.');
212
+ (0, ui_1.info)(' Remove it with `cku dashboard logout`, or sign in with --no-save-password.');
213
+ }
214
+ else {
215
+ (0, ui_1.info)(' Password not saved. The token cannot be refreshed, so re-run this once it expires.');
216
+ }
198
217
  }
199
218
  catch (err) {
200
219
  (0, ui_1.handleError)(err);
@@ -203,7 +222,7 @@ async function dashboardLoginCommand(opts) {
203
222
  /** `cku dashboard whoami` — prove the stored session still works. */
204
223
  async function dashboardWhoamiCommand(opts) {
205
224
  try {
206
- const { sessionToken, email } = requireDashboard();
225
+ const { sessionToken, email } = await requireDashboard();
207
226
  const res = await fetch(`${(0, config_1.backendUrl)()}/auth/get-user`, {
208
227
  headers: { Cookie: `access_token=${sessionToken}` },
209
228
  });
@@ -238,7 +257,7 @@ async function dashboardProbeCommand() {
238
257
  '/phone-number/',
239
258
  ];
240
259
  try {
241
- const { token, sessionToken, email } = requireDashboard();
260
+ const { token, sessionToken, email } = await requireDashboard();
242
261
  const base = (0, config_1.backendUrl)();
243
262
  const send = (path, which) => fetch(`${base}${path}`, {
244
263
  headers: which === 'bearer' ? { Authorization: `Bearer ${token}` } : { Cookie: `access_token=${sessionToken}` },
@@ -318,16 +337,8 @@ async function dashboardLogoutCommand(opts) {
318
337
  const email = slug ? (0, config_1.loadAccount)(slug).email : undefined;
319
338
  if (!hasDashboard(email))
320
339
  return void (0, ui_1.info)('No dashboard session stored for this account.');
321
- if (!opts.yes) {
322
- const { ok } = await (0, prompts_1.default)({
323
- type: 'confirm',
324
- name: 'ok',
325
- message: `Forget the dashboard session for ${email}? (your ck_ token is kept)`,
326
- initial: false,
327
- });
328
- if (!ok)
329
- return void (0, ui_1.info)('Cancelled.');
330
- }
340
+ if (!(await (0, ui_1.confirm)(`Forget the dashboard session for ${email}? (your ck_ token is kept)`, opts.yes)))
341
+ return;
331
342
  if (email && forgetDashboard(email))
332
343
  (0, ui_1.success)(`Dashboard session removed for ${email}.`);
333
344
  else
package/dist/lib/ui.js CHANGED
@@ -6,11 +6,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.fail = exports.info = exports.success = void 0;
7
7
  exports.banner = banner;
8
8
  exports.keyValue = keyValue;
9
+ exports.needTty = needTty;
10
+ exports.confirm = confirm;
9
11
  exports.table = table;
10
12
  exports.handleError = handleError;
11
13
  exports.intFlag = intFlag;
12
14
  const chalk_1 = __importDefault(require("chalk"));
13
15
  const cli_table3_1 = __importDefault(require("cli-table3"));
16
+ const prompts_1 = __importDefault(require("prompts"));
14
17
  const types_1 = require("./types");
15
18
  const success = (msg) => console.log(chalk_1.default.green('✔') + ' ' + msg);
16
19
  exports.success = success;
@@ -46,9 +49,35 @@ function keyValue(obj) {
46
49
  console.log(`${chalk_1.default.dim(k.padEnd(width))} ${v}`);
47
50
  }
48
51
  }
52
+ /**
53
+ * Throw when a prompt is about to run with nobody to answer it. `prompts` waits
54
+ * on stdin forever in a non-interactive shell, so a script or agent hangs
55
+ * instead of failing. `hint` names the flag that skips the prompt.
56
+ */
57
+ function needTty(hint) {
58
+ if (!process.stdin.isTTY) {
59
+ throw new Error(`This needs input, but there is no terminal to ask on. ${hint}`);
60
+ }
61
+ }
62
+ /** Ask y/N, defaulting to no. `--yes` skips it; no terminal and no `--yes` is an error, not a hang. */
63
+ async function confirm(message, yes) {
64
+ if (yes)
65
+ return true;
66
+ needTty('Re-run with --yes to confirm.');
67
+ const { ok } = await (0, prompts_1.default)({ type: 'confirm', name: 'ok', message, initial: false });
68
+ if (!ok)
69
+ (0, exports.info)('Cancelled.');
70
+ return Boolean(ok);
71
+ }
49
72
  /** Render rows as a bordered table with a highlighted header. */
50
73
  function table(headers, rows) {
51
- const t = new cli_table3_1.default({ head: headers.map((h) => chalk_1.default.cyan(h)) });
74
+ // cli-table3 colours borders and header red/grey by default, independent of
75
+ // chalk, so piped output carried escape codes. Tie its style to chalk's level.
76
+ const colour = chalk_1.default.level > 0;
77
+ const t = new cli_table3_1.default({
78
+ head: headers.map((h) => chalk_1.default.cyan(h)),
79
+ style: { head: [], border: colour ? ['grey'] : [] },
80
+ });
52
81
  for (const row of rows) {
53
82
  t.push(row.map((cell) => (cell === undefined || cell === null ? '' : String(cell))));
54
83
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "callkaro-cli-unofficial",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "license": "Apache-2.0",
5
- "description": "Unofficial fork of the CallKaro CLI \u2014 manage your voice AI agents from the terminal",
5
+ "description": "Unofficial fork of the CallKaro CLI — manage your voice AI agents from the terminal",
6
6
  "bin": {
7
7
  "cku": "dist/index.js"
8
8
  },