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/lib/api.js CHANGED
@@ -12,30 +12,64 @@ const types_1 = require("./types");
12
12
  * logic. Public methods map 1:1 to the `/cli/*` endpoints.
13
13
  */
14
14
  class Api {
15
- token;
16
15
  monitoringAccountId;
16
+ renewToken;
17
17
  baseUrl;
18
18
  callBaseUrl;
19
- constructor(baseUrl, callBaseUrl, token, monitoringAccountId) {
20
- this.token = token;
19
+ /** Mutable: a renewed token replaces the rejected one for later requests. */
20
+ token;
21
+ constructor(baseUrl, callBaseUrl, token, monitoringAccountId,
22
+ /**
23
+ * Re-issue a rejected token. The `ck_` token is opaque and has no refresh
24
+ * route, so the only way back in is to sign in again — which needs the
25
+ * account's password. Returns the new token, or undefined when the account
26
+ * cannot renew itself.
27
+ */
28
+ renewToken) {
21
29
  this.monitoringAccountId = monitoringAccountId;
30
+ this.renewToken = renewToken;
22
31
  this.baseUrl = baseUrl.replace(/\/+$/, '');
23
32
  this.callBaseUrl = (callBaseUrl || baseUrl).replace(/\/+$/, '');
33
+ this.token = token;
24
34
  }
25
35
  request(method, path, body, includeAccount = true) {
26
36
  return this.requestTo(this.baseUrl, method, path, body, includeAccount);
27
37
  }
28
38
  /** Same as request(), but against a specific base URL (e.g. the call cloud). */
29
39
  async requestTo(base, method, path, body, includeAccount = true) {
40
+ let res = await this.send(base, method, path, body, includeAccount, this.token);
41
+ // A rejected token is not a failure the user can act on by retrying: the
42
+ // same token gets the same answer. Renew once, silently, when the account
43
+ // has the means to renew itself. The renewed token then serves the rest of
44
+ // the command, so one renewal covers every request in it.
45
+ if (res.status === 401 && this.renewToken) {
46
+ const fresh = await this.renewToken();
47
+ if (fresh) {
48
+ this.token = fresh;
49
+ res = await this.send(base, method, path, body, includeAccount, fresh);
50
+ }
51
+ }
52
+ const text = await res.text();
53
+ const data = text ? safeJson(text) : undefined;
54
+ if (!res.ok) {
55
+ throw new types_1.ApiError(res.status, res.status === 401
56
+ ? `${data?.msg || 'Your session is no longer valid'} — the stored token was rejected and could not be renewed.\n` +
57
+ ' Run `cku login` to sign in again.'
58
+ : data?.msg || data?.message || (typeof data?.error === 'string' ? data.error : undefined) ||
59
+ `Request failed (${res.status})`, parseErrorDetails(data?.errors ?? data?.data?.errors ?? invalidDetails(data?.invalid)));
60
+ }
61
+ return data;
62
+ }
63
+ /** One HTTP round trip, with the given bearer token. */
64
+ async send(base, method, path, body, includeAccount, token) {
30
65
  const headers = { 'Content-Type': 'application/json' };
31
- if (this.token)
32
- headers.Authorization = `Bearer ${this.token}`;
66
+ if (token)
67
+ headers.Authorization = `Bearer ${token}`;
33
68
  // Monitoring mode: every request, writes included, acts on the client account.
34
69
  if (includeAccount && this.monitoringAccountId)
35
70
  headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
36
- let res;
37
71
  try {
38
- res = await fetch(`${base}${path}`, {
72
+ return await fetch(`${base}${path}`, {
39
73
  method,
40
74
  headers,
41
75
  body: body === undefined ? undefined : JSON.stringify(body),
@@ -44,13 +78,6 @@ class Api {
44
78
  catch (err) {
45
79
  throw new types_1.ApiError(0, `Cannot reach ${base} (${err.message})`);
46
80
  }
47
- const text = await res.text();
48
- const data = text ? safeJson(text) : undefined;
49
- if (!res.ok) {
50
- throw new types_1.ApiError(res.status, data?.msg || data?.message || (typeof data?.error === 'string' ? data.error : undefined) ||
51
- `Request failed (${res.status})`, parseErrorDetails(data?.errors));
52
- }
53
- return data;
54
81
  }
55
82
  // --- auth endpoints -------------------------------------------------------
56
83
  registerStart(email) { return this.request('POST', '/cli/auth/register/start', { email }); }
@@ -66,6 +93,98 @@ class Api {
66
93
  me() { return this.request('GET', '/cli/auth/me'); }
67
94
  /** Client accounts assigned to this user. Sent without the monitoring header: it lists, not acts. */
68
95
  listClientAccounts() { return this.request('GET', '/cli/auth/accounts', undefined, false); }
96
+ // --- workflows ------------------------------------------------------------
97
+ // The workflow controllers answer { success, message, data }; callers get `data`.
98
+ async workflow(method, path, body) {
99
+ return (await this.request(method, `/cli/workflows${path}`, body)).data;
100
+ }
101
+ listWorkflows(search) {
102
+ const q = new URLSearchParams({ limit: '100', type: 'normal' });
103
+ if (search)
104
+ q.set('search', search);
105
+ return this.workflow('GET', `?${q}`);
106
+ }
107
+ getWorkflow(id) { return this.workflow('GET', `/${id}`); }
108
+ createWorkflow(payload) { return this.workflow('POST', '', payload); }
109
+ updateWorkflow(id, payload) { return this.workflow('PUT', `/${id}`, payload); }
110
+ deleteWorkflow(id) { return this.workflow('DELETE', `/${id}`); }
111
+ duplicateWorkflow(id, workFlowName) {
112
+ return this.workflow('POST', `/${id}/duplicate`, { workFlowName });
113
+ }
114
+ validateWorkflowGraph(graph) { return this.workflow('POST', '/validate', { graph }); }
115
+ listWorkflowLeads(id, query = {}) {
116
+ return this.workflow('GET', `/${id}/leads${qs(query)}`);
117
+ }
118
+ getWorkflowLead(id, phone) {
119
+ return this.workflow('GET', `/${id}/leads/${encodeURIComponent(phone)}`);
120
+ }
121
+ getWorkflowRunByPhone(id, phone) {
122
+ return this.workflow('GET', `/${id}/runs/by-phone/${encodeURIComponent(phone)}`);
123
+ }
124
+ /** Every call to/from a number inside the workflow. Answers { success, calls }, not { data }. */
125
+ workflowJourney(id, phone) {
126
+ return this.request('GET', `/cli/workflows/${id}/journey/${encodeURIComponent(phone)}`);
127
+ }
128
+ // --- contacts (CRM) -------------------------------------------------------
129
+ // Unlike workflows, these controllers answer plain JSON ({ msg, contacts, ... }).
130
+ listContacts(query = {}) { return this.request('GET', `/cli/contacts${qs(query)}`); }
131
+ getContact(phone) { return this.request('GET', `/cli/contacts/detail/${encodeURIComponent(phone)}`); }
132
+ /** Upsert: creates the contact, or merges into an existing one. */
133
+ saveContact(body) { return this.request('POST', '/cli/contacts', body); }
134
+ updateContactAttributes(phone, attributes) {
135
+ return this.request('PATCH', `/cli/contacts/detail/${encodeURIComponent(phone)}`, { attributes });
136
+ }
137
+ deleteContact(phone) { return this.request('DELETE', `/cli/contacts/detail/${encodeURIComponent(phone)}`); }
138
+ assignContacts(body) { return this.request('POST', '/cli/contacts/assign', body); }
139
+ listContactAttributes() { return this.request('GET', '/cli/contacts/attributes'); }
140
+ saveContactAttribute(definition) { return this.request('POST', '/cli/contacts/attributes', definition); }
141
+ deleteContactAttribute(name) {
142
+ return this.request('DELETE', `/cli/contacts/attributes/${encodeURIComponent(name)}`);
143
+ }
144
+ /** CSV upload (multipart). Columns must match defined attributes unless ignoreUnknown. */
145
+ async uploadContacts(filePath, ignoreUnknown) {
146
+ const form = new FormData();
147
+ form.append('file', new Blob([(0, fs_1.readFileSync)(filePath)], { type: 'text/csv' }), (0, path_1.basename)(filePath));
148
+ if (ignoreUnknown)
149
+ form.append('ignoreUnknownColumns', 'true');
150
+ const headers = {};
151
+ if (this.token)
152
+ headers.Authorization = `Bearer ${this.token}`;
153
+ if (this.monitoringAccountId)
154
+ headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
155
+ const res = await fetch(`${this.baseUrl}/cli/contacts/upload`, { method: 'POST', headers, body: form });
156
+ const text = await res.text();
157
+ const data = text ? safeJson(text) : {};
158
+ if (!res.ok) {
159
+ const unknown = data?.unknownColumns || [];
160
+ const hint = unknown.length
161
+ ? ' Pass --ignore-unknown to drop them, or define them with `cku contacts attributes set`.'
162
+ : '';
163
+ throw new types_1.ApiError(res.status, `${data?.msg || `Request failed (${res.status})`}${hint}`, parseErrorDetails(data?.errors ?? data?.data?.errors ?? invalidDetails(data?.invalid)));
164
+ }
165
+ return data;
166
+ }
167
+ /** CSV text for the filtered set, or { processing } when the server emails it instead. */
168
+ async exportContacts(query = {}) {
169
+ const headers = {};
170
+ if (this.token)
171
+ headers.Authorization = `Bearer ${this.token}`;
172
+ if (this.monitoringAccountId)
173
+ headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
174
+ let res;
175
+ try {
176
+ res = await fetch(`${this.baseUrl}/cli/contacts/export${qs(query)}`, { headers });
177
+ }
178
+ catch (err) {
179
+ throw new types_1.ApiError(0, `Cannot reach ${this.baseUrl} (${err.message})`);
180
+ }
181
+ const text = await res.text();
182
+ if (!res.ok)
183
+ throw new types_1.ApiError(res.status, safeJson(text)?.msg || `Request failed (${res.status})`);
184
+ if ((res.headers.get('content-type') || '').includes('application/json'))
185
+ return safeJson(text);
186
+ return { csv: text };
187
+ }
69
188
  // --- secrets --------------------------------------------------------------
70
189
  listSecrets() { return this.request('GET', '/cli/secrets'); }
71
190
  saveSecrets(secrets) { return this.request('PUT', '/cli/secrets', { secrets }); }
@@ -179,6 +298,8 @@ class Api {
179
298
  const headers = {};
180
299
  if (this.token)
181
300
  headers.Authorization = `Bearer ${this.token}`;
301
+ if (this.monitoringAccountId)
302
+ headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
182
303
  const res = await fetch(`${this.baseUrl}/cli/calls/export${qs(query)}`, { headers });
183
304
  const contentType = res.headers.get('content-type') || '';
184
305
  if (contentType.includes('application/json')) {
@@ -231,6 +352,8 @@ class Api {
231
352
  // Let fetch set the multipart boundary — do not set Content-Type here.
232
353
  if (this.token)
233
354
  headers.Authorization = `Bearer ${this.token}`;
355
+ if (this.monitoringAccountId)
356
+ headers['X-CallKaro-Account-Id'] = this.monitoringAccountId;
234
357
  const res = await fetch(url, { method: 'POST', headers, body: form });
235
358
  const text = await res.text();
236
359
  const data = text ? safeJson(text) : {};
@@ -246,6 +369,7 @@ class Api {
246
369
  return this.request('GET', `/cli/voices/provider/${encodeURIComponent(provider)}${qs(params)}`);
247
370
  }
248
371
  // --- simulations ----------------------------------------------------------
372
+ simulationModels() { return this.request('GET', '/cli/simulations/models'); }
249
373
  createTestCase(body) { return this.request('POST', '/cli/simulations/test-cases', body); }
250
374
  listTestCases(agentId) {
251
375
  return this.request('GET', `/cli/simulations/test-cases${qs({ agentId })}`);
@@ -289,6 +413,17 @@ function safeJson(text) {
289
413
  return { msg: text };
290
414
  }
291
415
  }
416
+ /** The CRM's per-value refusals: [{ attribute, reason, value }] -> one line each. */
417
+ function invalidDetails(value) {
418
+ if (!Array.isArray(value))
419
+ return undefined;
420
+ return value.flatMap((v) => v && typeof v === 'object' && typeof v.reason === 'string'
421
+ ? [{
422
+ message: `${v.reason}${v.value !== undefined ? ` (got "${v.value}")` : ''}`,
423
+ path: v.attribute || v.column,
424
+ }]
425
+ : []);
426
+ }
292
427
  function parseErrorDetails(value) {
293
428
  if (!Array.isArray(value))
294
429
  return [];
@@ -30,6 +30,7 @@ exports.saveLogin = saveLogin;
30
30
  exports.monitoringAccountId = monitoringAccountId;
31
31
  exports.setMonitoring = setMonitoring;
32
32
  exports.getClient = getClient;
33
+ exports.renewTokenFor = renewTokenFor;
33
34
  const fs_1 = require("fs");
34
35
  const os_1 = require("os");
35
36
  const path_1 = require("path");
@@ -238,7 +239,16 @@ function saveAccount(email, session) {
238
239
  // so without this a later `cku login` would silently drop a dashboard session
239
240
  // the account already holds.
240
241
  const dashboard = session.dashboard ?? loadAccount(slug).dashboard;
241
- writeJson(accountPath(slug), { ...session, email, savedAt: new Date().toISOString(), ...(dashboard ? { dashboard } : {}) });
242
+ // The password is the one field carried across a re-login: it is what lets a
243
+ // rejected token be re-issued without the user signing in again.
244
+ const password = session.password ?? loadAccount(slug).password;
245
+ writeJson(accountPath(slug), {
246
+ ...session,
247
+ email,
248
+ savedAt: new Date().toISOString(),
249
+ ...(dashboard ? { dashboard } : {}),
250
+ ...(password ? { password } : {}),
251
+ });
242
252
  setActiveAccount(email);
243
253
  return slug;
244
254
  }
@@ -323,8 +333,10 @@ function clearSession() {
323
333
  if (!(0, fs_1.existsSync)(path))
324
334
  return undefined;
325
335
  const { email, savedAt, dashboard } = loadAccount(slug);
326
- // Dropping the ck_ token is what logout means. A dashboard session is a
327
- // separate credential and is left in place — `cku firebase logout` removes it.
336
+ // Dropping the ck_ token is what logout means. The password goes with it: it
337
+ // exists only to re-issue that token, so keeping it would let a logged-out
338
+ // account sign itself back in. A dashboard session is a separate credential
339
+ // and is left in place — `cku dashboard logout` removes it.
328
340
  writeJson(path, { email, savedAt, ...(dashboard ? { dashboard } : {}) });
329
341
  return email;
330
342
  }
@@ -365,5 +377,48 @@ function setMonitoring(monitoring) {
365
377
  /** Build the API client from the endpoint URLs + resolved token. */
366
378
  function getClient() {
367
379
  const cfg = loadConfig();
368
- return { api: new api_1.Api(backendUrl(), callUrl(), cfg.token, monitoringAccountId(cfg)), cfg };
380
+ return {
381
+ api: new api_1.Api(backendUrl(), callUrl(), cfg.token, monitoringAccountId(cfg), renewTokenFor(cfg)),
382
+ cfg,
383
+ };
384
+ }
385
+ /**
386
+ * Sign in again for an account that stored its password, and persist the new
387
+ * token.
388
+ *
389
+ * The `ck_` token is opaque and has no refresh route, so a token the server
390
+ * stops accepting leaves no way back in except a fresh sign-in. An account that
391
+ * stored its password renews itself; one that did not cannot, and the caller
392
+ * gets undefined so the error can name `cku login`.
393
+ *
394
+ * Under CK_TOKEN there is no stored account in effect, so nothing is renewed:
395
+ * the environment owns the credential.
396
+ */
397
+ function renewTokenFor(cfg = loadConfig()) {
398
+ if (process.env.CK_TOKEN)
399
+ return undefined;
400
+ const email = cfg.email;
401
+ const password = cfg.password;
402
+ if (!email || !password)
403
+ return undefined;
404
+ return async () => {
405
+ try {
406
+ const res = await fetch(`${backendUrl()}/cli/auth/login`, {
407
+ method: 'POST',
408
+ headers: { 'Content-Type': 'application/json' },
409
+ body: JSON.stringify({ email, password }),
410
+ });
411
+ const data = (await res.json().catch(() => ({})));
412
+ if (!res.ok || !data.token)
413
+ return undefined;
414
+ // Persist the new token, keeping everything else the account holds.
415
+ const slug = slugFor(email);
416
+ const current = loadAccount(slug);
417
+ writeJson(accountPath(slug), { ...current, email, token: data.token, savedAt: new Date().toISOString() });
418
+ return data.token;
419
+ }
420
+ catch {
421
+ return undefined;
422
+ }
423
+ };
369
424
  }
@@ -0,0 +1,183 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.handlesOf = exports.ADDABLE_TYPES = void 0;
4
+ exports.addNode = addNode;
5
+ exports.setNode = setNode;
6
+ exports.removeNode = removeNode;
7
+ exports.connect = connect;
8
+ exports.disconnect = disconnect;
9
+ /**
10
+ * Pure edits on a workflow graph. Each takes a graph, mutates it in place and
11
+ * returns a one-line summary; none talks to the server. The server still
12
+ * validates on save, so these only guard what would make an edit meaningless
13
+ * (unknown ids, a handle that does not exist) and keep the shapes the canvas
14
+ * writes: one edge per (from, fromHandle), exit ids on `conditions` nodes.
15
+ */
16
+ const DEFAULT_HANDLE = 'out';
17
+ const X_STEP = 320;
18
+ // Mirrors NODE_META in the dashboard's WorkflowCanvas.jsx.
19
+ const DEFAULT_NAMES = {
20
+ end: 'End',
21
+ 'send-call': 'Send Call',
22
+ 'wa-template': 'WhatsApp Template',
23
+ 'wa-message': 'WhatsApp Message',
24
+ wait: 'Wait',
25
+ conditions: 'Conditions',
26
+ 'api-basic': 'API Call',
27
+ 'api-advanced': 'Custom Function',
28
+ 'update-lead': 'Update Contact',
29
+ };
30
+ exports.ADDABLE_TYPES = Object.keys(DEFAULT_NAMES);
31
+ // Fields with no sensible default: the server refuses the node until they are set, so say so up front.
32
+ const NEEDS = {
33
+ 'send-call': { fields: ['agentId'], example: '{"agentId":"<id from cku agents list>"}' },
34
+ 'wa-template': { fields: ['templateId'], example: 'a template entry (see cku workflows schema)' },
35
+ 'wa-message': { fields: ['message'], example: '{"message":"Hello","phoneNumberId":"…","displayPhoneNumber":"…","provider":"whatsapp","parameters":[]}' },
36
+ 'api-basic': { fields: ['url'], example: '{"method":"POST","url":"https://…"}' },
37
+ 'update-lead': { fields: ['updates'], example: '{"updates":[{"attribute":"stage","source":"static_value","value":"contacted"}]}' },
38
+ };
39
+ let counter = 0;
40
+ const stamp = () => `${Date.now()}${counter++ % 10}`;
41
+ const exitId = () => `o_${Math.random().toString(36).slice(2, 10)}`;
42
+ const defaultsFor = (type) => {
43
+ switch (type) {
44
+ case 'send-call':
45
+ return { retries: 0, retryGapMinutes: 30, retryGaps: [] };
46
+ case 'wait':
47
+ return { minutes: 15 };
48
+ case 'update-lead':
49
+ return { updates: [{ attribute: '', source: 'post_call_variables', value: '' }] };
50
+ case 'api-advanced':
51
+ return { code: 'async function handler(context) {\n // Everything is on context, e.g.\n // context.call_metadata.lead_id\n}' };
52
+ // The dashboard seeds an incomplete rule and lets the canvas hold it as a draft; a CLI edit
53
+ // saves at once, so this starts as a complete one ("did the call connect?"). Change it with set-node.
54
+ case 'conditions':
55
+ return {
56
+ exits: [
57
+ { id: exitId(), label: 'connected', next: '', condition: { source: 'connected_call', field: '', op: 'eq', value: true } },
58
+ { id: exitId(), label: 'otherwise', next: '' },
59
+ ],
60
+ };
61
+ default:
62
+ return {};
63
+ }
64
+ };
65
+ const nodeOf = (g, id) => {
66
+ const node = g.nodes.find((n) => n.id === id);
67
+ if (!node)
68
+ throw new Error(`No node with id "${id}". Ids: ${g.nodes.map((n) => n.id).join(', ')}`);
69
+ return node;
70
+ };
71
+ const handlesOf = (node) => node.type === 'conditions' ? (node.exits || []).map((e) => e.id) : [DEFAULT_HANDLE];
72
+ exports.handlesOf = handlesOf;
73
+ /** The handle to use when the caller named none: the only one, or an error listing the choices. */
74
+ function resolveHandle(node, handle) {
75
+ const handles = (0, exports.handlesOf)(node);
76
+ if (handle) {
77
+ if (!handles.includes(handle)) {
78
+ throw new Error(`"${node.id}" has no handle "${handle}". Handles: ${handles.join(', ')}`);
79
+ }
80
+ return handle;
81
+ }
82
+ if (handles.length !== 1) {
83
+ throw new Error(`"${node.id}" has several branches; pass --handle. Handles: ${handles.join(', ')}`);
84
+ }
85
+ return handles[0];
86
+ }
87
+ const edgeFrom = (g, from, handle) => g.edges.find((e) => e.from === from && e.fromHandle === handle);
88
+ /** One edge per (from, fromHandle): connecting replaces whatever left that handle. */
89
+ function setEdge(g, from, handle, to) {
90
+ g.edges = g.edges.filter((e) => !(e.from === from && e.fromHandle === handle));
91
+ g.edges.push({ id: `edge-${stamp()}`, from, fromHandle: handle, to });
92
+ }
93
+ function addNode(g, opts) {
94
+ if (!exports.ADDABLE_TYPES.includes(opts.type)) {
95
+ throw new Error(`--type must be one of: ${exports.ADDABLE_TYPES.join(', ')} (a workflow has exactly one start, which already exists)`);
96
+ }
97
+ const anchor = opts.after ? nodeOf(g, opts.after) : undefined;
98
+ if (anchor?.type === 'end')
99
+ throw new Error(`Nothing can follow "${anchor.id}": a run stops at an End node.`);
100
+ const afterHandle = anchor ? resolveHandle(anchor, opts.handle) : undefined;
101
+ const id = `${opts.type}-${stamp()}`;
102
+ const node = {
103
+ id,
104
+ type: opts.type,
105
+ name: opts.name ?? DEFAULT_NAMES[opts.type],
106
+ ...defaultsFor(opts.type),
107
+ ...(opts.set || {}),
108
+ x: opts.x ?? (anchor ? Math.round((anchor.x ?? 0) + X_STEP) : Math.round(Math.max(0, ...g.nodes.map((n) => n.x ?? 0)) + X_STEP)),
109
+ y: opts.y ?? Math.round(anchor?.y ?? g.nodes[0]?.y ?? 180),
110
+ };
111
+ node.id = id;
112
+ node.type = opts.type;
113
+ const need = NEEDS[opts.type];
114
+ const empty = (v) => v === undefined || v === '' || (Array.isArray(v) && (v.length === 0 || v.every((r) => !r?.attribute)));
115
+ if (need && need.fields.some((f) => empty(node[f]))) {
116
+ throw new Error(`A ${opts.type} node needs ${need.fields.join(', ')}: pass --set, e.g. --set '${need.example}'`);
117
+ }
118
+ g.nodes.push(node);
119
+ let note = '';
120
+ if (anchor && afterHandle) {
121
+ const previous = edgeFrom(g, anchor.id, afterHandle)?.to;
122
+ setEdge(g, anchor.id, afterHandle, id);
123
+ if (previous) {
124
+ if (opts.type === 'end') {
125
+ note = ` "${previous}" is no longer connected (an End has nothing after it).`;
126
+ }
127
+ else {
128
+ const exits = node.exits || [];
129
+ const out = node.type === 'conditions' ? (exits.find((e) => !e.condition)?.id ?? exits[exits.length - 1].id) : DEFAULT_HANDLE;
130
+ setEdge(g, id, out, previous);
131
+ }
132
+ }
133
+ }
134
+ return `Added ${opts.type} node "${id}"${anchor ? ` after "${anchor.id}"` : ' (not connected yet; use `connect`)'}.${note}`;
135
+ }
136
+ function setNode(g, nodeId, fields) {
137
+ const node = nodeOf(g, nodeId);
138
+ const { id: _id, type: _type, ...rest } = fields;
139
+ if ((_id !== undefined && _id !== nodeId) || (_type !== undefined && _type !== node.type)) {
140
+ throw new Error("A node's id and type cannot be changed; remove it and add a new one.");
141
+ }
142
+ Object.assign(node, rest);
143
+ const live = (0, exports.handlesOf)(node);
144
+ const before = g.edges.length;
145
+ g.edges = g.edges.filter((e) => e.from !== nodeId || live.includes(e.fromHandle));
146
+ const dropped = before - g.edges.length;
147
+ return `Updated "${nodeId}".${dropped ? ` Removed ${dropped} edge(s) from branches that no longer exist.` : ''}`;
148
+ }
149
+ function removeNode(g, nodeId, reconnect = false) {
150
+ const node = nodeOf(g, nodeId);
151
+ if (node.type === 'start')
152
+ throw new Error('The Start node cannot be removed.');
153
+ const next = node.type !== 'conditions' ? edgeFrom(g, nodeId, DEFAULT_HANDLE)?.to : undefined;
154
+ const incoming = g.edges.filter((e) => e.to === nodeId);
155
+ if (reconnect && !next) {
156
+ throw new Error(`--reconnect needs "${nodeId}" to have a single next step to bridge to; it has none.`);
157
+ }
158
+ g.nodes = g.nodes.filter((n) => n.id !== nodeId);
159
+ g.edges = g.edges.filter((e) => e.from !== nodeId && e.to !== nodeId);
160
+ if (reconnect && next)
161
+ incoming.forEach((e) => setEdge(g, e.from, e.fromHandle, next));
162
+ return `Removed "${nodeId}" and its edges${reconnect && next ? `; ${incoming.length} incoming edge(s) now lead to "${next}"` : ''}.`;
163
+ }
164
+ function connect(g, from, to, handle) {
165
+ const source = nodeOf(g, from);
166
+ const target = nodeOf(g, to);
167
+ if (source.type === 'end')
168
+ throw new Error(`Nothing can follow "${from}": a run stops at an End node.`);
169
+ if (target.type === 'start')
170
+ throw new Error(`Nothing can lead into "${to}": a run begins at Start.`);
171
+ const h = resolveHandle(source, handle);
172
+ const replaced = edgeFrom(g, from, h)?.to;
173
+ setEdge(g, from, h, to);
174
+ return `Connected "${from}" (${h}) → "${to}".${replaced && replaced !== to ? ` Replaced the old link to "${replaced}".` : ''}`;
175
+ }
176
+ function disconnect(g, from, handle) {
177
+ const h = resolveHandle(nodeOf(g, from), handle);
178
+ const edge = edgeFrom(g, from, h);
179
+ if (!edge)
180
+ throw new Error(`"${from}" (${h}) is not connected to anything.`);
181
+ g.edges = g.edges.filter((e) => e !== edge);
182
+ return `Disconnected "${from}" (${h}) from "${edge.to}".`;
183
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "callkaro-cli-unofficial",
3
- "version": "0.4.1",
3
+ "version": "0.5.1",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Unofficial fork of the CallKaro CLI — manage your voice AI agents from the terminal",
6
6
  "bin": {