wldx-admin-cli 1.2.0 → 1.3.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.
@@ -0,0 +1,719 @@
1
+ // wx-admin kb: the knowledge base of the Wildix cloud (WIM) for the company of the PBX, with the PBX's CPA token:
2
+ // knowledge bases, search and answers; data sources (kb source) and documents (kb doc). A knowledge base, a data
3
+ // source or a document is given by id or by exact name; a name several share asks for the id.
4
+ // Operations (the knowledge base service, https://kb.wim[-stage].wildix.com/api/v1):
5
+ // ListKnowledgeBases, GetKnowledgeBase, CreateKnowledgeBase, UpdateKnowledgeBase, DeleteKnowledgeBase,
6
+ // SearchKnowledgeBase, GenerateSearchAnswer, ListSearchQueriesHistory, ListHistoryStatistics;
7
+ // ListDataSources, GetDataSource, CreateDataSource, ConfigureDataSource, DescribeDataSource, StartSyncDataSource,
8
+ // StopSyncDataSource, GetDataSourceSyncStatus, ListDataSourceSynchronizationLogs, ListDataSourceStatistics,
9
+ // CloneDataSource, CleanDataSource, DeleteDataSource;
10
+ // ListDocuments, GetDocumentWithContentAndChunks, GetDocumentMarkdown, CreateDocument, GetDocumentUploadUrl,
11
+ // GetDocumentDownloadUrl, DeleteDocument (uploads and downloads go to presigned links, without the token)
12
+ import path from 'node:path';
13
+ import {UsageError} from 'wldx-core/errors';
14
+ import {readForUpload} from 'wldx-core/files';
15
+ import {apiPath} from 'wldx-core/http';
16
+ import {localTime, oneLine, paint, table} from 'wldx-core/format';
17
+ import {readSecret} from 'wldx-core/secret';
18
+ import {cloudClient} from '../cloud/client.mjs';
19
+ import {operationsOf} from '../cloud/specs.mjs';
20
+ import {pickByName} from '../match.mjs';
21
+ import {checkNewFile, writeNewFile} from './records.mjs';
22
+ import {maskCloud} from '../cloud/mask.mjs';
23
+
24
+ const V1 = '/api/v1';
25
+ const kbClient = (ctx) => cloudClient(ctx, 'kb');
26
+ const masked = (ctx, data) => (ctx.values['show-secrets'] ? data : maskCloud(data));
27
+ const SHOW = {
28
+ 'show-secrets': {type: 'boolean', description: 'show keys and tokens of data sources (masked otherwise)'},
29
+ };
30
+
31
+ const byIdOrName = (list, ref, noun, hint) =>
32
+ list.find((x) => x.id === String(ref)) ?? pickByName(list, ref, (x) => x.name ?? x.title, noun, hint);
33
+
34
+ export async function listKbs(client) {
35
+ return (await client.call('GET', `${V1}/knowledge-bases`))?.knowledgeBases ?? [];
36
+ }
37
+ export async function listSources(client) {
38
+ return (await client.call('GET', `${V1}/data-sources`))?.dataSources ?? [];
39
+ }
40
+ export async function findKb(client, ref) {
41
+ return byIdOrName(await listKbs(client), ref, 'knowledge base', 'wx-admin kb list');
42
+ }
43
+ export async function findSource(client, ref) {
44
+ return byIdOrName(await listSources(client), ref, 'data source', 'wx-admin kb source list');
45
+ }
46
+ export async function findDoc(client, sourceId, ref) {
47
+ const docs = [];
48
+ for (let offset = 0; ; offset += 100) {
49
+ const page =
50
+ (await client.call('GET', `${V1}${apiPath`/data-sources/${sourceId}/documents`}`, {query: {limit: 100, offset}}))
51
+ ?.documents ?? [];
52
+ docs.push(...page);
53
+ if (docs.some((d) => d.id === String(ref)) || page.length < 100) break;
54
+ }
55
+ return byIdOrName(docs, ref, 'document', `wx-admin kb doc list ${sourceId}`);
56
+ }
57
+
58
+ const kbPath = (id) => `${V1}${apiPath`/knowledge-bases/${id}`}`;
59
+ const sourceNames = (sources) => {
60
+ const names = new Map(sources.map((s) => [s.id, s.name]));
61
+ return (ids = []) => ids.map((i) => names.get(i) ?? i);
62
+ };
63
+ const kbLine = (k, names) =>
64
+ `${k.name} (${k.id}) · ${names(k.dataSources).join(', ') || 'no sources'} · updated ${localTime(k.updatedAt)}`;
65
+ const resultLines = (results) =>
66
+ results.map(
67
+ (r) =>
68
+ `${Number(r.score).toFixed(2)} ${r.documentTitle} ${r.documentUrl ?? ''}\n ${oneLine(r.content, 160)}`,
69
+ );
70
+
71
+ // What PUT takes: name, description, dataSources (all of them).
72
+ const kbBody = (k) => ({name: k.name, description: k.description ?? '', dataSources: k.dataSources ?? []});
73
+
74
+ export const kbCommands = {
75
+ list: {
76
+ summary: 'the knowledge bases of the company: name, data sources, last change',
77
+ usage: 'wx-admin kb list',
78
+ options: {},
79
+ args: [0, 0],
80
+ async run(ctx) {
81
+ const client = await kbClient(ctx);
82
+ const [kbs, sources] = [await listKbs(client), await listSources(client)];
83
+ const names = sourceNames(sources);
84
+ return {knowledgeBases: kbs.map((k) => ({...k, dataSourceNames: names(k.dataSources)}))};
85
+ },
86
+ text: (r) =>
87
+ r.knowledgeBases.length ? r.knowledgeBases.map((k) => kbLine(k, () => k.dataSourceNames)) : 'No knowledge bases',
88
+ },
89
+ get: {
90
+ summary: 'one knowledge base with its data sources by name',
91
+ usage: 'wx-admin kb get <kb>',
92
+ options: {},
93
+ args: [1, 1],
94
+ async run(ctx) {
95
+ const client = await kbClient(ctx);
96
+ const k = (await client.call('GET', kbPath((await findKb(client, ctx.positionals[0])).id)))?.knowledgeBase;
97
+ return {...k, dataSourceNames: sourceNames(await listSources(client))(k.dataSources)};
98
+ },
99
+ text: (k) =>
100
+ table([
101
+ ['Name', `${k.name} (${k.id})`],
102
+ ['Description', k.description || ''],
103
+ ['Sources', k.dataSourceNames.join(', ')],
104
+ ['Updated', localTime(k.updatedAt)],
105
+ ]),
106
+ },
107
+ create: {
108
+ summary: 'create a knowledge base from data sources',
109
+ usage: 'wx-admin kb create <name> --source DS... [--description TEXT]',
110
+ options: {
111
+ source: {type: 'string', multiple: true, placeholder: 'DS', description: 'a data source (id or name)'},
112
+ description: {type: 'string', placeholder: 'TEXT', description: 'the description'},
113
+ },
114
+ args: [1, 1],
115
+ async run(ctx) {
116
+ if (!ctx.values.source?.length) throw new UsageError('Give its data sources: --source DS', ctx.usage());
117
+ const client = await kbClient(ctx);
118
+ const sources = await listSources(client);
119
+ const ids = ctx.values.source.map((s) => byIdOrName(sources, s, 'data source', 'wx-admin kb source list').id);
120
+ const body = {name: ctx.positionals[0], description: ctx.values.description ?? '', dataSources: ids};
121
+ return (await client.call('POST', `${V1}/knowledge-bases`, {json: body}))?.knowledgeBase ?? body;
122
+ },
123
+ text: (k) => `Created the knowledge base ${k.name}${k.id ? ` (${k.id})` : ''}`,
124
+ },
125
+ update: {
126
+ summary: 'change a knowledge base: name, description, data sources (--source replaces them: needs --yes)',
127
+ usage: 'wx-admin kb update <kb> [--name NAME] [--description TEXT] [--source DS]... [--yes]',
128
+ options: {
129
+ name: {type: 'string', placeholder: 'NAME', description: 'the new name'},
130
+ description: {type: 'string', placeholder: 'TEXT', description: 'the new description'},
131
+ source: {type: 'string', multiple: true, placeholder: 'DS', description: 'its data sources (replaces them all)'},
132
+ yes: {type: 'boolean', description: 'yes, replace the data sources'},
133
+ },
134
+ args: [1, 1],
135
+ async run(ctx) {
136
+ const v = ctx.values;
137
+ if (v.name === undefined && v.description === undefined && !v.source)
138
+ throw new UsageError('Nothing to change: give --name, --description or --source', ctx.usage());
139
+ if (v.source && !v.yes)
140
+ throw new UsageError(
141
+ '--source replaces every data source of the knowledge base: confirm with --yes',
142
+ ctx.usage(),
143
+ );
144
+ const client = await kbClient(ctx);
145
+ const k = await findKb(client, ctx.positionals[0]);
146
+ const body = kbBody(k);
147
+ if (v.name !== undefined) body.name = v.name;
148
+ if (v.description !== undefined) body.description = v.description;
149
+ if (v.source) {
150
+ const sources = await listSources(client);
151
+ body.dataSources = v.source.map((s) => byIdOrName(sources, s, 'data source', 'wx-admin kb source list').id);
152
+ }
153
+ return (await client.call('PUT', kbPath(k.id), {json: body}))?.knowledgeBase ?? {...k, ...body};
154
+ },
155
+ text: (k) => `Updated the knowledge base ${k.name} (${k.id})`,
156
+ },
157
+ delete: {
158
+ summary: 'delete knowledge bases (needs --yes; their data sources stay)',
159
+ usage: 'wx-admin kb delete <kb>... --yes',
160
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
161
+ args: [1, 50],
162
+ async run(ctx) {
163
+ if (!ctx.values.yes)
164
+ throw new UsageError('Deleting a knowledge base cannot be undone: confirm with --yes', ctx.usage());
165
+ const client = await kbClient(ctx);
166
+ const found = [];
167
+ for (const ref of ctx.positionals) found.push(await findKb(client, ref));
168
+ for (const k of found) await client.call('DELETE', kbPath(k.id));
169
+ return {deleted: found.map(({id, name}) => ({id, name}))};
170
+ },
171
+ text: (r) => `Deleted ${r.deleted.map((k) => `${k.name} (${k.id})`).join(', ')}`,
172
+ },
173
+ search: {
174
+ summary: 'search a knowledge base: the chunks found, with their document and score',
175
+ usage: 'wx-admin kb search <kb> <query> [--limit N]',
176
+ options: {limit: {type: 'string', placeholder: 'N', description: 'at most N chunks (default 5)'}},
177
+ args: [2, 2],
178
+ async run(ctx) {
179
+ const limit = Number(ctx.values.limit ?? 5);
180
+ if (!Number.isInteger(limit) || limit < 1) throw new UsageError(`--limit is a whole number: ${ctx.values.limit}`);
181
+ const client = await kbClient(ctx);
182
+ const k = await findKb(client, ctx.positionals[0]);
183
+ return client.call('POST', `${kbPath(k.id)}/search`, {json: {query: ctx.positionals[1], searchLimit: limit}});
184
+ },
185
+ text: (r) => (r?.results?.length ? resultLines(r.results) : 'Nothing found'),
186
+ },
187
+ ask: {
188
+ summary: 'an answer from knowledge bases: search each, then generate the answer from what was found',
189
+ usage: 'wx-admin kb ask <query> --kb KB... [--limit N] [--model MODEL]',
190
+ options: {
191
+ kb: {type: 'string', multiple: true, placeholder: 'KB', description: 'a knowledge base to search (id or name)'},
192
+ limit: {type: 'string', placeholder: 'N', description: 'the chunks the answer is made from (default 5)'},
193
+ model: {type: 'string', placeholder: 'MODEL', description: "the model of the answer (default: the service's)"},
194
+ },
195
+ args: [1, 1],
196
+ async run(ctx) {
197
+ if (!ctx.values.kb?.length) throw new UsageError('Give the knowledge bases: --kb KB', ctx.usage());
198
+ const limit = Number(ctx.values.limit ?? 5);
199
+ if (!Number.isInteger(limit) || limit < 1) throw new UsageError(`--limit is a whole number: ${ctx.values.limit}`);
200
+ const query = ctx.positionals[0];
201
+ const client = await kbClient(ctx);
202
+ const kbs = await listKbs(client);
203
+ const results = [];
204
+ for (const ref of ctx.values.kb) {
205
+ const k = byIdOrName(kbs, ref, 'knowledge base', 'wx-admin kb list');
206
+ const r = await client.call('POST', `${kbPath(k.id)}/search`, {json: {query, searchLimit: limit}});
207
+ results.push(...(r?.results ?? []));
208
+ }
209
+ const best = results.sort((a, b) => b.score - a.score).slice(0, limit);
210
+ const body = {searchQuery: query, searchResults: best, ...(ctx.values.model ? {model: ctx.values.model} : {})};
211
+ return client.call('POST', `${V1}/knowledge-bases/generate-answer`, {json: body});
212
+ },
213
+ text(r, ctx) {
214
+ const c = paint(ctx);
215
+ const m = r.metadata ?? {};
216
+ return [
217
+ r.answer,
218
+ '',
219
+ 'Sources:',
220
+ ...(r.sources ?? []).map((s) => ` ${s.documentTitle} ${s.documentUrl ?? ''}`),
221
+ c.dim(`${m.model ?? ''} · ${m.inputTokens ?? '?'} in, ${m.outputTokens ?? '?'} out · ${m.duration ?? '?'} ms`),
222
+ ];
223
+ },
224
+ },
225
+ history: {
226
+ summary: 'the search queries made on the knowledge bases',
227
+ usage: 'wx-admin kb history [--limit N]',
228
+ options: {limit: {type: 'string', placeholder: 'N', description: 'at most N queries'}},
229
+ args: [0, 0],
230
+ async run(ctx) {
231
+ const client = await kbClient(ctx);
232
+ return client.call('POST', `${V1}/knowledge-bases/history/search-queries`, {
233
+ query: {limit: ctx.values.limit},
234
+ json: {},
235
+ });
236
+ },
237
+ },
238
+ stats: {
239
+ summary: 'statistics of the searches on the knowledge bases',
240
+ usage: 'wx-admin kb stats',
241
+ options: {},
242
+ args: [0, 0],
243
+ async run(ctx) {
244
+ return (await kbClient(ctx)).call('POST', `${V1}/knowledge-bases/history/statistics`, {json: {}});
245
+ },
246
+ },
247
+ };
248
+
249
+ // ---------------------------------------------------------------------------------------------------------------------
250
+ // Data sources
251
+
252
+ const dsPath = (id) => `${V1}${apiPath`/data-sources/${id}`}`;
253
+ const enumOf = (op, prop, fallback) => {
254
+ const p = operationsOf('kb').get(op)?.schema?.properties?.[prop];
255
+ return p?.enum ?? p?.allOf?.find((x) => x.enum)?.enum ?? fallback;
256
+ };
257
+ const SCHEDULES = enumOf('ConfigureDataSource', 'syncSchedule', [
258
+ 'DISABLED',
259
+ 'EVERY_SIX_HOURS',
260
+ 'EVERY_TWELVE_HOURS',
261
+ 'EVERY_DAY',
262
+ 'EVERY_WEEK',
263
+ ]);
264
+ const TYPES = ['confluence', 'website', 'files'];
265
+ const LEVELS = ['debug', 'info', 'warn', 'error'];
266
+ const RUNNING = /run|sync|progress|pend|queue|start/i;
267
+ const sourceLine = (d) => `${d.name} (${d.id}) · ${d.type} · ${d.syncSchedule ?? ''}`;
268
+ const whole = (v, name) => {
269
+ if (v === undefined) return undefined;
270
+ if (!/^\d+$/.test(String(v))) throw new UsageError(`--${name} is a whole number: ${v}`);
271
+ return Number(v);
272
+ };
273
+ const statusLine = (r) =>
274
+ `${r.status}${r.mode ? ` (${r.mode})` : ''} · ${
275
+ Object.entries(r.documentsStats ?? {})
276
+ .map(([k, n]) => `${n} ${k}`)
277
+ .join(', ') || 'no documents'
278
+ }${r.syncedAt ? ` · synced ${localTime(r.syncedAt)}` : ''}${r.error ? ` · ${r.error}` : ''}`;
279
+
280
+ export const sourceCommands = {
281
+ list: {
282
+ summary: 'the data sources: type, schedule (keys masked)',
283
+ usage: 'wx-admin kb source list [--show-secrets]',
284
+ options: {...SHOW},
285
+ args: [0, 0],
286
+ async run(ctx) {
287
+ return masked(ctx, {dataSources: await listSources(await kbClient(ctx))});
288
+ },
289
+ text: (r) => (r.dataSources.length ? r.dataSources.map(sourceLine) : 'No data sources'),
290
+ },
291
+ get: {
292
+ summary: 'one data source with its configuration and sync status (keys masked)',
293
+ usage: 'wx-admin kb source get <ds> [--show-secrets]',
294
+ options: {...SHOW},
295
+ args: [1, 1],
296
+ async run(ctx) {
297
+ const client = await kbClient(ctx);
298
+ const d = await findSource(client, ctx.positionals[0]);
299
+ const full = (await client.call('GET', dsPath(d.id)))?.dataSource ?? d;
300
+ const syncStatus = await client.call('GET', `${dsPath(d.id)}/sync-status`);
301
+ return masked(ctx, {...full, syncStatus});
302
+ },
303
+ text: (r) => [
304
+ sourceLine(r),
305
+ ` sync: ${statusLine(r.syncStatus ?? {})}`,
306
+ ...JSON.stringify(r.config ?? {}, null, 2)
307
+ .split('\n')
308
+ .map((l) => ` ${l}`),
309
+ ],
310
+ },
311
+ create: {
312
+ summary: 'create a data source (then: kb source configure, kb source sync)',
313
+ usage: 'wx-admin kb source create <name> --type confluence|website|files [--description TEXT]',
314
+ details:
315
+ 'Google Drive is connected with OAuth in a browser: connect it in the WIM console; wx-admin then lists, syncs and deletes it.',
316
+ options: {
317
+ type: {type: 'string', choices: [...TYPES, 'gdrive'], description: 'the kind of source'},
318
+ description: {type: 'string', placeholder: 'TEXT', description: 'the description'},
319
+ },
320
+ args: [1, 1],
321
+ async run(ctx) {
322
+ const type = ctx.values.type;
323
+ if (!type) throw new UsageError('--type is needed', ctx.usage());
324
+ if (type === 'gdrive')
325
+ throw new UsageError(
326
+ 'Google Drive is connected with OAuth in a browser: connect it in the WIM console, then wx-admin kb source sync',
327
+ );
328
+ const body = {name: ctx.positionals[0], description: ctx.values.description ?? '', type};
329
+ return (await (await kbClient(ctx)).call('POST', `${V1}/data-sources`, {json: body}))?.dataSource ?? body;
330
+ },
331
+ text: (d) =>
332
+ `Created the data source ${d.name}${d.id ? ` (${d.id})` : ''}: next, wx-admin kb source configure ${d.id ?? d.name}`,
333
+ },
334
+ configure: {
335
+ summary:
336
+ 'configure a data source: Confluence (address, user, space, pages; the API token typed hidden), website, schedule',
337
+ usage:
338
+ 'wx-admin kb source configure <ds> [--url URL]... [--user EMAIL] [--space ID] [--page ID]... [--schedule SCHEDULE]',
339
+ details: [
340
+ 'Confluence: --url (https://<site>.atlassian.net), --user, --space, --page (replaces the pages); the API token is',
341
+ 'asked hidden (or read from stdin) when the source has none or --url or --user change. Website: --url (repeatable).',
342
+ `Schedule: ${SCHEDULES.join(', ')}.`,
343
+ ].join('\n'),
344
+ options: {
345
+ url: {
346
+ type: 'string',
347
+ multiple: true,
348
+ placeholder: 'URL',
349
+ description: 'the Confluence address, or the website(s)',
350
+ },
351
+ user: {type: 'string', placeholder: 'EMAIL', description: 'the Confluence user of the API token'},
352
+ space: {type: 'string', placeholder: 'ID', description: 'the Confluence space (kb source browse lists them)'},
353
+ page: {
354
+ type: 'string',
355
+ multiple: true,
356
+ placeholder: 'ID',
357
+ description: 'a Confluence or website page to sync (replaces them)',
358
+ },
359
+ schedule: {type: 'string', choices: SCHEDULES, description: 'how often it syncs'},
360
+ },
361
+ args: [1, 1],
362
+ async run(ctx) {
363
+ const v = ctx.values;
364
+ const client = await kbClient(ctx);
365
+ const d = await findSource(client, ctx.positionals[0]);
366
+ const full = (await client.call('GET', dsPath(d.id)))?.dataSource ?? d;
367
+ let config;
368
+ if (full.type === 'confluence') {
369
+ const c = {...(full.config?.confluence ?? {})};
370
+ const needKey = !c.apiKey || v.url !== undefined || v.user !== undefined;
371
+ if (v.url) c.baseUrl = v.url[0];
372
+ if (v.user !== undefined) c.username = v.user;
373
+ if (v.space !== undefined) c.spaceId = v.space;
374
+ if (v.page) c.pages = {enabled: v.page, disabled: c.pages?.disabled ?? []};
375
+ for (const k of ['baseUrl', 'username', 'spaceId'])
376
+ if (!c[k])
377
+ throw new UsageError(
378
+ `A Confluence source needs ${{baseUrl: '--url', username: '--user', spaceId: '--space'}[k]}`,
379
+ ctx.usage(),
380
+ );
381
+ if (needKey) {
382
+ c.apiKey = await readSecret(ctx, `Confluence API token of ${c.username}: `);
383
+ if (!c.apiKey) throw new UsageError('No API token given: nothing was changed');
384
+ }
385
+ config = {confluence: c};
386
+ } else if (full.type === 'website') {
387
+ const w = {...(full.config?.website ?? {})};
388
+ if (v.url) w.urls = v.url;
389
+ if (v.page) w.pages = {enabled: v.page};
390
+ if (!w.urls?.length) throw new UsageError('A website source needs --url', ctx.usage());
391
+ config = {website: w};
392
+ } else if (full.type === 'gdrive') {
393
+ config = {gdrive: full.config?.gdrive};
394
+ } else config = {files: {}};
395
+ const body = {
396
+ config,
397
+ ...(v.schedule ? {syncSchedule: v.schedule} : full.syncSchedule ? {syncSchedule: full.syncSchedule} : {}),
398
+ };
399
+ const r = await client.call('PUT', `${dsPath(d.id)}/configure`, {json: body});
400
+ return maskCloud(r?.dataSource ?? {id: d.id, name: d.name, ...body});
401
+ },
402
+ text: (d) => `Configured the data source ${d.name} (${d.id})`,
403
+ },
404
+ browse: {
405
+ summary: 'what a source can sync: Confluence spaces (or the pages of --space), website pages, Drive folders',
406
+ usage: 'wx-admin kb source browse <ds> [--space ID] [--url URL] [--folder ID]',
407
+ options: {
408
+ space: {type: 'string', placeholder: 'ID', description: 'Confluence: the pages of this space'},
409
+ url: {type: 'string', placeholder: 'URL', description: 'website: discover from this address (default: its own)'},
410
+ folder: {
411
+ type: 'string',
412
+ placeholder: 'ID',
413
+ description: 'Google Drive: the folders under this one (default root)',
414
+ },
415
+ },
416
+ args: [1, 1],
417
+ async run(ctx) {
418
+ const v = ctx.values;
419
+ const client = await kbClient(ctx);
420
+ const d = await findSource(client, ctx.positionals[0]);
421
+ let parameters;
422
+ if (d.type === 'confluence') parameters = {confluence: v.space ? {pages: {spaceId: v.space}} : {spaces: {}}};
423
+ else if (d.type === 'website') {
424
+ const url = v.url ?? d.config?.website?.urls?.[0];
425
+ if (!url) throw new UsageError('Give the address: --url URL', ctx.usage());
426
+ parameters = {website: {discover: {url}}};
427
+ } else if (d.type === 'gdrive') parameters = {gdrive: {folders: {parentId: v.folder ?? 'root'}}};
428
+ else throw new UsageError(`A ${d.type} source has nothing to browse`);
429
+ return (await client.call('POST', `${dsPath(d.id)}/describe`, {json: {parameters}}))?.result ?? {};
430
+ },
431
+ text(r) {
432
+ const c = r.confluence;
433
+ if (c?.spaces) return c.spaces.map((s) => `${s.id} ${s.key ?? ''} ${s.name} (${s.type})`);
434
+ if (c?.pages) return c.pages.map((p) => `${p.id} ${p.title}${p.parentId ? ` (in ${p.parentId})` : ''}`);
435
+ if (r.website) return [...r.website.pages.map((p) => p.url), ...(r.website.truncated ? ['… (truncated)'] : [])];
436
+ if (r.gdrive) return r.gdrive.folders.map((f) => `${f.id} ${f.name}`);
437
+ return JSON.stringify(r, null, 2);
438
+ },
439
+ },
440
+ sync: {
441
+ summary: 'start a sync of a data source (incremental; --full syncs everything again)',
442
+ usage: 'wx-admin kb source sync <ds> [--full]',
443
+ options: {full: {type: 'boolean', description: 'sync every document again, not only what changed'}},
444
+ args: [1, 1],
445
+ async run(ctx) {
446
+ const client = await kbClient(ctx);
447
+ const d = await findSource(client, ctx.positionals[0]);
448
+ await client.call('POST', `${dsPath(d.id)}/start-sync`, {
449
+ query: {syncType: ctx.values.full ? 'full' : 'incremental'},
450
+ });
451
+ return {id: d.id, name: d.name, sync: ctx.values.full ? 'full' : 'incremental', started: true};
452
+ },
453
+ text: (r) => `Started a ${r.sync} sync of ${r.name}: wx-admin kb source status ${r.id} --watch`,
454
+ },
455
+ stop: {
456
+ summary: 'stop the sync of a data source',
457
+ usage: 'wx-admin kb source stop <ds>',
458
+ options: {},
459
+ args: [1, 1],
460
+ async run(ctx) {
461
+ const client = await kbClient(ctx);
462
+ const d = await findSource(client, ctx.positionals[0]);
463
+ await client.call('POST', `${dsPath(d.id)}/stop-sync`);
464
+ return {id: d.id, name: d.name, stopped: true};
465
+ },
466
+ text: (r) => `Stopped the sync of ${r.name}`,
467
+ },
468
+ status: {
469
+ summary: 'the sync status of a data source; --watch follows it until the sync ends',
470
+ usage: 'wx-admin kb source status <ds> [--watch] [--every SECONDS]',
471
+ options: {
472
+ watch: {type: 'boolean', description: 'ask again until the sync is over'},
473
+ every: {type: 'string', placeholder: 'SECONDS', description: 'with --watch: how often (default 5)'},
474
+ },
475
+ args: [1, 1],
476
+ async run(ctx) {
477
+ const every = whole(ctx.values.every, 'every') ?? 5;
478
+ if (every < 1) throw new UsageError('--every is at least 1 (second)', ctx.usage());
479
+ const client = await kbClient(ctx);
480
+ const d = await findSource(client, ctx.positionals[0]);
481
+ let last;
482
+ for (;;) {
483
+ if (ctx.signal?.aborted && last) return {name: d.name, ...last}; // Ctrl-C while waiting: the last status
484
+ const r = await client.call('GET', `${dsPath(d.id)}/sync-status`);
485
+ last = r;
486
+ if (!ctx.values.watch || !RUNNING.test(String(r?.status ?? '')) || ctx.signal?.aborted)
487
+ return {name: d.name, ...r};
488
+ ctx.info(`${localTime(new Date(ctx.clock()).toISOString())} ${statusLine(r)}`);
489
+ await new Promise((resolve) => {
490
+ const t = setTimeout(resolve, every * 1000);
491
+ ctx.signal?.addEventListener(
492
+ 'abort',
493
+ () => {
494
+ clearTimeout(t);
495
+ resolve();
496
+ },
497
+ {once: true},
498
+ );
499
+ });
500
+ }
501
+ },
502
+ text: (r) => `${r.name}: ${statusLine(r)}`,
503
+ },
504
+ logs: {
505
+ summary: 'the sync logs of a data source',
506
+ usage: 'wx-admin kb source logs <ds> [--level LEVEL] [--search TEXT] [--limit N]',
507
+ options: {
508
+ level: {type: 'string', choices: LEVELS, description: 'only this level'},
509
+ search: {type: 'string', placeholder: 'TEXT', description: 'only lines with this text'},
510
+ limit: {type: 'string', placeholder: 'N', description: 'at most N lines'},
511
+ },
512
+ args: [1, 1],
513
+ async run(ctx) {
514
+ const v = ctx.values;
515
+ const client = await kbClient(ctx);
516
+ const d = await findSource(client, ctx.positionals[0]);
517
+ return client.call('GET', `${dsPath(d.id)}/sync-logs`, {
518
+ query: {logLevel: v.level, search: v.search, limit: whole(v.limit, 'limit')},
519
+ });
520
+ },
521
+ text: (r) =>
522
+ (r?.logs ?? []).map((l) => `${localTime(l.timestamp)} ${String(l.level).padEnd(5)} ${oneLine(l.message, 200)}`),
523
+ },
524
+ stats: {
525
+ summary: 'the documents and chunks of a data source',
526
+ usage: 'wx-admin kb source stats <ds>',
527
+ options: {},
528
+ args: [1, 1],
529
+ async run(ctx) {
530
+ const client = await kbClient(ctx);
531
+ const d = await findSource(client, ctx.positionals[0]);
532
+ return {name: d.name, ...(await client.call('GET', `${dsPath(d.id)}/statistics`))};
533
+ },
534
+ text: (r) => `${r.name}: ${r.documentsCount} documents, ${r.chunksCount} chunks`,
535
+ },
536
+ clone: {
537
+ summary: 'a copy of a data source (its configuration, not its documents)',
538
+ usage: 'wx-admin kb source clone <ds>',
539
+ options: {},
540
+ args: [1, 1],
541
+ async run(ctx) {
542
+ const client = await kbClient(ctx);
543
+ const d = await findSource(client, ctx.positionals[0]);
544
+ return client.call('POST', `${dsPath(d.id)}/clone`);
545
+ },
546
+ text: (r) => `Cloned: the new data source is ${r.dataSourceId}`,
547
+ },
548
+ clean: {
549
+ summary: 'remove the documents of a data source (needs --yes; the source and its configuration stay)',
550
+ usage: 'wx-admin kb source clean <ds> --yes',
551
+ options: {yes: {type: 'boolean', description: 'yes, remove its documents'}},
552
+ args: [1, 1],
553
+ async run(ctx) {
554
+ if (!ctx.values.yes)
555
+ throw new UsageError('Cleaning removes every document of the source: confirm with --yes', ctx.usage());
556
+ const client = await kbClient(ctx);
557
+ const d = await findSource(client, ctx.positionals[0]);
558
+ await client.call('POST', `${dsPath(d.id)}/clean`);
559
+ return {id: d.id, name: d.name, cleaned: true};
560
+ },
561
+ text: (r) => `Cleaned ${r.name}`,
562
+ },
563
+ delete: {
564
+ summary: 'delete data sources (needs --yes)',
565
+ usage: 'wx-admin kb source delete <ds>... --yes',
566
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
567
+ args: [1, 50],
568
+ async run(ctx) {
569
+ if (!ctx.values.yes)
570
+ throw new UsageError('Deleting a data source removes its documents: confirm with --yes', ctx.usage());
571
+ const client = await kbClient(ctx);
572
+ const found = [];
573
+ for (const ref of ctx.positionals) found.push(await findSource(client, ref));
574
+ for (const d of found) await client.call('DELETE', dsPath(d.id));
575
+ return {deleted: found.map(({id, name}) => ({id, name}))};
576
+ },
577
+ text: (r) => `Deleted ${r.deleted.map((d) => `${d.name} (${d.id})`).join(', ')}`,
578
+ },
579
+ };
580
+
581
+ // ---------------------------------------------------------------------------------------------------------------------
582
+ // Documents
583
+
584
+ const docPath = (ds, doc) => `${V1}${apiPath`/data-sources/${ds}/documents/${doc}`}`;
585
+ const docLine = (d) =>
586
+ `${d.title} (${d.id}) · ${d.status ?? ''}${d.originalFormat ? ` · ${d.originalFormat}` : ''}${d.chunksCount !== undefined ? ` · ${d.chunksCount} chunks` : ''}`;
587
+
588
+ export const docCommands = {
589
+ list: {
590
+ summary: 'the documents of a data source',
591
+ usage: 'wx-admin kb doc list <ds> [--search TEXT] [--status STATUS] [--limit N]',
592
+ options: {
593
+ search: {type: 'string', placeholder: 'TEXT', description: 'only documents with this text in the title'},
594
+ status: {
595
+ type: 'string',
596
+ choices: ['draft', 'pending', 'processing', 'completed', 'failed'],
597
+ description: 'only this status',
598
+ },
599
+ limit: {type: 'string', placeholder: 'N', description: 'at most N documents'},
600
+ },
601
+ args: [1, 1],
602
+ async run(ctx) {
603
+ const v = ctx.values;
604
+ const client = await kbClient(ctx);
605
+ const d = await findSource(client, ctx.positionals[0]);
606
+ return client.call('GET', `${dsPath(d.id)}/documents`, {
607
+ query: {search: v.search, status: v.status, limit: whole(v.limit, 'limit')},
608
+ });
609
+ },
610
+ text: (r) => (r?.documents?.length ? r.documents.map(docLine) : 'No documents'),
611
+ },
612
+ get: {
613
+ summary: 'one document with its content and chunks',
614
+ usage: 'wx-admin kb doc get <ds> <doc>',
615
+ options: {},
616
+ args: [2, 2],
617
+ async run(ctx) {
618
+ const client = await kbClient(ctx);
619
+ const d = await findSource(client, ctx.positionals[0]);
620
+ const doc = await findDoc(client, d.id, ctx.positionals[1]);
621
+ return client.call('GET', `${docPath(d.id, doc.id)}/with-content-and-chunks`);
622
+ },
623
+ text: (r) => [docLine(r.document ?? {}), `${(r.chunks ?? []).length} chunks`, '', String(r.content ?? '')],
624
+ },
625
+ markdown: {
626
+ summary: 'the text of a document as Markdown (to stdout, or to a new file)',
627
+ usage: 'wx-admin kb doc markdown <ds> <doc> [--to FILE]',
628
+ options: {to: {type: 'string', placeholder: 'FILE', description: 'write it to this file (which must not exist)'}},
629
+ args: [2, 2],
630
+ async run(ctx) {
631
+ if (ctx.values.to) checkNewFile(ctx, ctx.values.to);
632
+ const client = await kbClient(ctx);
633
+ const d = await findSource(client, ctx.positionals[0]);
634
+ const doc = await findDoc(client, d.id, ctx.positionals[1]);
635
+ const markdown = (await client.call('GET', `${docPath(d.id, doc.id)}/markdown`))?.markdown ?? '';
636
+ if (!ctx.values.to) return {markdown};
637
+ return {file: writeNewFile(ctx, ctx.values.to, markdown), bytes: Buffer.byteLength(markdown)};
638
+ },
639
+ text: (r) => (r.file ? `Wrote ${r.file} (${r.bytes} bytes)` : r.markdown.replace(/\n$/, '')),
640
+ },
641
+ upload: {
642
+ summary: 'upload a file as a document of a data source',
643
+ usage: 'wx-admin kb doc upload <ds> <file> [--title TEXT]',
644
+ options: {title: {type: 'string', placeholder: 'TEXT', description: 'the title (default: the file name)'}},
645
+ args: [2, 2],
646
+ async run(ctx) {
647
+ const file = path.resolve(ctx.cwd ?? process.cwd(), ctx.positionals[1]);
648
+ const bytes = readForUpload(file, ctx.env); // no credentials, nothing hidden in the home folder: before anything is made
649
+ const name = path.basename(file);
650
+ const client = await kbClient(ctx);
651
+ const d = await findSource(client, ctx.positionals[0]);
652
+ const body = {
653
+ title: ctx.values.title ?? name.replace(/\.[^.]+$/, ''),
654
+ originalName: name,
655
+ originalFormat: path.extname(name).slice(1).toLowerCase(),
656
+ };
657
+ const made = await client.call('POST', `${dsPath(d.id)}/documents`, {json: body});
658
+ const doc = made?.document ?? made;
659
+ if (!doc?.id) throw new Error('The knowledge base did not answer the new document');
660
+ const {uploadUrl} = (await client.call('GET', `${docPath(d.id, doc.id)}/upload-url`)) ?? {};
661
+ if (!uploadUrl) throw new Error(`No upload link for the document ${doc.id}`);
662
+ const res = await client.raw('PUT', uploadUrl, {body: bytes});
663
+ if (!res.ok)
664
+ throw new Error(`The upload of ${name} failed (${res.status}); the document ${doc.id} waits for its file`);
665
+ return {...doc, uploaded: bytes.length};
666
+ },
667
+ text: (d) => `Uploaded ${d.title} (${d.id}, ${d.uploaded} bytes): it is processed now (wx-admin kb doc list)`,
668
+ },
669
+ download: {
670
+ summary: 'the original file of a document, to a new file',
671
+ usage: 'wx-admin kb doc download <ds> <doc> --to FILE',
672
+ options: {to: {type: 'string', placeholder: 'FILE', description: 'the new file'}},
673
+ args: [2, 2],
674
+ async run(ctx) {
675
+ if (!ctx.values.to) throw new UsageError('--to is needed', ctx.usage());
676
+ checkNewFile(ctx, ctx.values.to);
677
+ const client = await kbClient(ctx);
678
+ const d = await findSource(client, ctx.positionals[0]);
679
+ const doc = await findDoc(client, d.id, ctx.positionals[1]);
680
+ const {downloadUrl} = (await client.call('GET', `${docPath(d.id, doc.id)}/download-url`)) ?? {};
681
+ if (!downloadUrl) throw new Error(`No download link for the document ${doc.id}`);
682
+ const res = await client.raw('GET', downloadUrl);
683
+ if (!res.ok) throw new Error(`The download failed (${res.status})`);
684
+ const data = Buffer.from(await res.arrayBuffer());
685
+ return {file: writeNewFile(ctx, ctx.values.to, data), bytes: data.length};
686
+ },
687
+ text: (r) => `Wrote ${r.file} (${r.bytes} bytes)`,
688
+ },
689
+ delete: {
690
+ summary: 'delete documents of a data source (needs --yes)',
691
+ usage: 'wx-admin kb doc delete <ds> <doc>... --yes',
692
+ options: {yes: {type: 'boolean', description: 'yes, delete them'}},
693
+ args: [2, 51],
694
+ async run(ctx) {
695
+ if (!ctx.values.yes)
696
+ throw new UsageError('Deleting a document cannot be undone: confirm with --yes', ctx.usage());
697
+ const client = await kbClient(ctx);
698
+ const d = await findSource(client, ctx.positionals[0]);
699
+ const found = [];
700
+ for (const ref of ctx.positionals.slice(1)) found.push(await findDoc(client, d.id, ref));
701
+ for (const doc of found) await client.call('DELETE', docPath(d.id, doc.id));
702
+ return {deleted: found.map(({id, title}) => ({id, title}))};
703
+ },
704
+ text: (r) => `Deleted ${r.deleted.map((d) => `${d.title} (${d.id})`).join(', ')}`,
705
+ },
706
+ };
707
+
708
+ export const groups = {
709
+ kb: {
710
+ summary: 'the knowledge base of the cloud (WIM): knowledge bases, data sources, documents, search, answers',
711
+ commands: {
712
+ ...kbCommands,
713
+ source: {summary: 'the data sources of the knowledge bases', commands: sourceCommands},
714
+ doc: {summary: 'the documents of the data sources', commands: docCommands},
715
+ },
716
+ },
717
+ };
718
+
719
+ export {SHOW, masked};