@senso-ai/cli 0.8.1 → 0.9.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +508 -75
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -141,7 +141,7 @@ Options: `content list` supports `--limit`, `--offset`, `--search`, `--sort`. `c
141
141
  ```
142
142
  senso generate settings Get generation settings
143
143
  senso generate update-settings Update generation settings (--data)
144
- senso generate sample Generate sample for a prompt (--prompt-id, --content-type-id)
144
+ senso generate sample Generate sample for a prompt; waits for async job by default (--prompt-id, --content-type-id, --no-wait)
145
145
  senso generate run Trigger a content engine run (--prompt-ids)
146
146
  ```
147
147
 
package/dist/cli.js CHANGED
@@ -159,12 +159,30 @@ async function getLatestVersion() {
159
159
 
160
160
  // src/commands/auth.ts
161
161
  import * as p from "@clack/prompts";
162
+ import pc4 from "picocolors";
163
+
164
+ // src/lib/api-client.ts
162
165
  import pc3 from "picocolors";
163
166
 
167
+ // src/utils/logger.ts
168
+ import pc2 from "picocolors";
169
+ function success(msg) {
170
+ console.log(` ${pc2.green("\u2713")} ${msg}`);
171
+ }
172
+ function error(msg) {
173
+ console.error(` ${pc2.red("\u2717")} ${msg}`);
174
+ }
175
+ function warn(msg) {
176
+ console.error(` ${pc2.yellow("!")} ${msg}`);
177
+ }
178
+ function info(msg) {
179
+ console.log(` ${pc2.cyan("\u2139")} ${msg}`);
180
+ }
181
+
164
182
  // src/lib/api-client.ts
165
183
  var ApiError = class extends Error {
166
184
  constructor(status, statusText, body) {
167
- const msg = typeof body === "object" && body ? "error" in body ? body.error : "message" in body ? body.message : statusText : statusText;
185
+ const msg = extractErrorMessage(body, statusText);
168
186
  super(msg);
169
187
  this.status = status;
170
188
  this.statusText = statusText;
@@ -172,6 +190,19 @@ var ApiError = class extends Error {
172
190
  this.name = "ApiError";
173
191
  }
174
192
  };
193
+ function extractErrorMessage(body, fallback) {
194
+ if (typeof body !== "object" || !body) return fallback;
195
+ const b = body;
196
+ if (typeof b.error === "string") return b.error;
197
+ if (typeof b.message === "string") return b.message;
198
+ if (typeof b.detail === "string") return b.detail;
199
+ if (Array.isArray(b.errors) && b.errors.length > 0) {
200
+ return b.errors.map(
201
+ (e) => e.field ? `${e.field}: ${e.message}` : String(e.message || e)
202
+ ).join("; ");
203
+ }
204
+ return fallback;
205
+ }
175
206
  async function apiRequest(opts) {
176
207
  const apiKey = getApiKey({ apiKey: opts.apiKey });
177
208
  if (!apiKey) {
@@ -204,20 +235,20 @@ async function apiRequest(opts) {
204
235
  });
205
236
  if (!res.ok) {
206
237
  let body;
207
- const text3 = await res.text();
238
+ const text5 = await res.text();
208
239
  try {
209
- body = JSON.parse(text3);
240
+ body = JSON.parse(text5);
210
241
  } catch {
211
- body = text3;
242
+ body = text5;
212
243
  }
213
244
  throw new ApiError(res.status, res.statusText, body);
214
245
  }
215
246
  if (res.status === 204) {
216
247
  return void 0;
217
248
  }
218
- const text2 = await res.text();
249
+ const text4 = await res.text();
219
250
  try {
220
- return JSON.parse(text2);
251
+ return JSON.parse(text4);
221
252
  } catch {
222
253
  throw new Error(`Invalid JSON response from ${opts.path}`);
223
254
  }
@@ -225,6 +256,87 @@ async function apiRequest(opts) {
225
256
  clearTimeout(timeout);
226
257
  }
227
258
  }
259
+ async function apiStreamRequest(opts) {
260
+ const apiKey = getApiKey({ apiKey: opts.apiKey });
261
+ if (!apiKey) {
262
+ throw new Error(
263
+ "No API key found. Run `senso login` or set SENSO_API_KEY."
264
+ );
265
+ }
266
+ const baseUrl = getBaseUrl({ baseUrl: opts.baseUrl });
267
+ const url = new URL(`${baseUrl}${opts.path}`);
268
+ const res = await fetch(url.toString(), {
269
+ method: opts.method || "POST",
270
+ headers: {
271
+ "X-API-Key": apiKey,
272
+ Accept: "text/event-stream",
273
+ ...opts.body ? { "Content-Type": "application/json" } : {},
274
+ "User-Agent": `senso-cli/${version}`
275
+ },
276
+ body: opts.body ? JSON.stringify(opts.body) : void 0
277
+ });
278
+ if (!res.ok) {
279
+ let body;
280
+ const text4 = await res.text();
281
+ try {
282
+ body = JSON.parse(text4);
283
+ } catch {
284
+ body = text4;
285
+ }
286
+ throw new ApiError(res.status, res.statusText, body);
287
+ }
288
+ return res;
289
+ }
290
+ function uploadStatusToReason(status, error2) {
291
+ switch (status) {
292
+ case "conflict":
293
+ return "A file with the same content already exists in your knowledge base.";
294
+ case "duplicate":
295
+ return "This file has already been uploaded.";
296
+ case "invalid":
297
+ return error2 || "This file type is not supported.";
298
+ default:
299
+ return error2 || `Unexpected status: ${status}`;
300
+ }
301
+ }
302
+ function printUploadSummary(uploaded, failed, items) {
303
+ const total = items.length;
304
+ console.log();
305
+ console.log(` ${pc3.bold("Upload Summary")} \u2014 ${uploaded}/${total} file(s) uploaded`);
306
+ console.log();
307
+ if (uploaded > 0) {
308
+ for (const item of items) {
309
+ if (item.status === "upload_pending" && !failed.find((f) => f.filename === item.filename)) {
310
+ success(`${item.filename}`);
311
+ }
312
+ }
313
+ }
314
+ if (failed.length > 0) {
315
+ for (const f of failed) {
316
+ error(`${f.filename} \u2014 ${f.reason}`);
317
+ }
318
+ }
319
+ if (uploaded > 0) {
320
+ console.log();
321
+ info("Background processing will parse, chunk, and embed the uploaded files.");
322
+ }
323
+ if (uploaded === 0 && total > 0) {
324
+ console.log();
325
+ error("No files were uploaded. Please review the issues above and try again.");
326
+ }
327
+ }
328
+ function handleUploadError(err) {
329
+ if (err instanceof ApiError && err.body && typeof err.body === "object" && "results" in err.body) {
330
+ const errorResponse = err.body;
331
+ for (const item of errorResponse.results ?? []) {
332
+ const reason = uploadStatusToReason(item.status, item.error);
333
+ error(`${item.filename} \u2014 ${reason}`);
334
+ }
335
+ error("No files were uploaded. Please review the issues above and try again.");
336
+ } else {
337
+ error(formatApiError(err));
338
+ }
339
+ }
228
340
  function formatApiError(err) {
229
341
  if (err instanceof ApiError) {
230
342
  switch (err.status) {
@@ -257,21 +369,6 @@ function formatApiError(err) {
257
369
  return String(err);
258
370
  }
259
371
 
260
- // src/utils/logger.ts
261
- import pc2 from "picocolors";
262
- function success(msg) {
263
- console.log(` ${pc2.green("\u2713")} ${msg}`);
264
- }
265
- function error(msg) {
266
- console.error(` ${pc2.red("\u2717")} ${msg}`);
267
- }
268
- function warn(msg) {
269
- console.error(` ${pc2.yellow("!")} ${msg}`);
270
- }
271
- function info(msg) {
272
- console.log(` ${pc2.cyan("\u2139")} ${msg}`);
273
- }
274
-
275
372
  // src/commands/auth.ts
276
373
  async function verifyApiKey(apiKey, baseUrl) {
277
374
  return apiRequest({
@@ -284,10 +381,10 @@ function registerAuthCommands(program2) {
284
381
  program2.command("login").description("Authenticate with Senso. Paste your API key and it will be validated against your organization, then stored locally.").action(async () => {
285
382
  const opts = program2.opts();
286
383
  banner();
287
- console.log(` ${pc3.bold("Welcome to Senso CLI!")}
384
+ console.log(` ${pc4.bold("Welcome to Senso CLI!")}
288
385
  `);
289
- console.log(` ${pc3.dim("1.")} Go to ${pc3.cyan("https://docs.senso.ai")} to create an account`);
290
- console.log(` ${pc3.dim("2.")} Generate an API key from your dashboard
386
+ console.log(` ${pc4.dim("1.")} Go to ${pc4.cyan("https://docs.senso.ai")} to create an account`);
387
+ console.log(` ${pc4.dim("2.")} Generate an API key from your dashboard
291
388
  `);
292
389
  const result = await p.text({
293
390
  message: "Paste your API key:",
@@ -314,8 +411,8 @@ function registerAuthCommands(program2) {
314
411
  orgSlug: org.slug,
315
412
  isFreeTier: org.is_free_tier
316
413
  });
317
- success(`Authenticated as ${pc3.bold(`"${org.name}"`)} (${pc3.dim(org.org_id)})`);
318
- success(`Config saved to ${pc3.dim(getConfigPath())}`);
414
+ success(`Authenticated as ${pc4.bold(`"${org.name}"`)} (${pc4.dim(org.org_id)})`);
415
+ success(`Config saved to ${pc4.dim(getConfigPath())}`);
319
416
  console.log();
320
417
  } catch (err) {
321
418
  spin.stop("Verification failed");
@@ -350,19 +447,19 @@ function registerAuthCommands(program2) {
350
447
  );
351
448
  } else {
352
449
  console.log();
353
- console.log(` ${pc3.bold("Organization:")} ${org.name}`);
354
- console.log(` ${pc3.bold("Org ID:")} ${org.org_id}`);
355
- console.log(` ${pc3.bold("Slug:")} ${org.slug}`);
356
- console.log(` ${pc3.bold("Tier:")} ${org.is_free_tier ? "Free" : "Paid"}`);
357
- console.log(` ${pc3.bold("API Key:")} ${apiKey.slice(0, 8)}...`);
358
- console.log(` ${pc3.bold("Config:")} ${getConfigPath()}`);
450
+ console.log(` ${pc4.bold("Organization:")} ${org.name}`);
451
+ console.log(` ${pc4.bold("Org ID:")} ${org.org_id}`);
452
+ console.log(` ${pc4.bold("Slug:")} ${org.slug}`);
453
+ console.log(` ${pc4.bold("Tier:")} ${org.is_free_tier ? "Free" : "Paid"}`);
454
+ console.log(` ${pc4.bold("API Key:")} ${apiKey.slice(0, 8)}...`);
455
+ console.log(` ${pc4.bold("Config:")} ${getConfigPath()}`);
359
456
  console.log();
360
457
  }
361
458
  } catch (err) {
362
459
  if (config.orgName) {
363
460
  warn(`Could not reach API: ${formatApiError(err)}`);
364
- console.log(` ${pc3.bold("Organization:")} ${config.orgName} ${pc3.dim("(cached)")}`);
365
- console.log(` ${pc3.bold("Org ID:")} ${config.orgId}`);
461
+ console.log(` ${pc4.bold("Organization:")} ${config.orgName} ${pc4.dim("(cached)")}`);
462
+ console.log(` ${pc4.bold("Org ID:")} ${config.orgId}`);
366
463
  } else {
367
464
  error(formatApiError(err));
368
465
  process.exit(1);
@@ -615,16 +712,16 @@ function registerApiKeyCommands(program2) {
615
712
  }
616
713
 
617
714
  // src/commands/search.ts
618
- import pc5 from "picocolors";
715
+ import pc6 from "picocolors";
619
716
 
620
717
  // src/lib/output.ts
621
- import pc4 from "picocolors";
718
+ import pc5 from "picocolors";
622
719
  function outputJson(data) {
623
720
  console.log(JSON.stringify(data, null, 2));
624
721
  }
625
722
  function outputTable(rows, columns) {
626
723
  if (rows.length === 0) {
627
- console.log(pc4.dim(" No results."));
724
+ console.log(pc5.dim(" No results."));
628
725
  return;
629
726
  }
630
727
  const cols = columns || Object.keys(rows[0]);
@@ -635,7 +732,7 @@ function outputTable(rows, columns) {
635
732
  );
636
733
  return Math.max(col.length, maxVal);
637
734
  });
638
- const header = cols.map((col, i) => pc4.bold(col.padEnd(widths[i]))).join(" ");
735
+ const header = cols.map((col, i) => pc5.bold(col.padEnd(widths[i]))).join(" ");
639
736
  console.log(` ${header}`);
640
737
  console.log(` ${widths.map((w) => "\u2500".repeat(w)).join(" ")}`);
641
738
  for (const row of rows) {
@@ -702,12 +799,12 @@ function registerSearchCommands(program2) {
702
799
  } : void 0,
703
800
  plain: [
704
801
  "",
705
- res.answer ? ` ${pc5.bold("Answer:")} ${res.answer}` : "",
802
+ res.answer ? ` ${pc6.bold("Answer:")} ${res.answer}` : "",
706
803
  "",
707
804
  ...(res.results || []).map(
708
- (r, i) => ` ${pc5.dim(`${i + 1}.`)} ${pc5.bold(String(r.title || "Untitled"))}
805
+ (r, i) => ` ${pc6.dim(`${i + 1}.`)} ${pc6.bold(String(r.title || "Untitled"))}
709
806
  ${String(r.chunk_text || "").slice(0, 120)}
710
- ${pc5.dim(`ID: ${r.content_id}`)}`
807
+ ${pc6.dim(`ID: ${r.content_id}`)}`
711
808
  ),
712
809
  ""
713
810
  ].filter(Boolean)
@@ -774,6 +871,86 @@ function registerSearchCommands(program2) {
774
871
  process.exit(1);
775
872
  }
776
873
  });
874
+ search.command("stream <query>").description("Streaming search \u2014 returns AI answer tokens in real-time via SSE, followed by source chunks. Use this for a responsive, live search experience.").option("--max-results <n>", "Maximum results (max: 20)", "5").option("--content-ids <ids...>", "Restrict search to specific content item IDs (space-separated UUIDs)").option("--require-scoped-ids", "Only return results from the specified --content-ids").action(async (query, cmdOpts) => {
875
+ const opts = program2.opts();
876
+ const body = { query, max_results: parseMaxResults(cmdOpts.maxResults) };
877
+ if (cmdOpts.contentIds) body.content_ids = cmdOpts.contentIds;
878
+ if (cmdOpts.requireScopedIds) body.require_scoped_ids = true;
879
+ try {
880
+ const res = await apiStreamRequest({
881
+ method: "POST",
882
+ path: "/org/search/stream",
883
+ body,
884
+ apiKey: opts.apiKey,
885
+ baseUrl: opts.baseUrl
886
+ });
887
+ if (!res.body) {
888
+ error("No response body received.");
889
+ process.exit(1);
890
+ }
891
+ const reader = res.body.getReader();
892
+ const decoder = new TextDecoder();
893
+ let buffer = "";
894
+ let eventType = null;
895
+ let answerStarted = false;
896
+ while (true) {
897
+ const { done, value } = await reader.read();
898
+ if (done) break;
899
+ buffer += decoder.decode(value, { stream: true });
900
+ const lines = buffer.split("\n");
901
+ buffer = lines.pop() || "";
902
+ for (const line of lines) {
903
+ if (line.startsWith("event: ")) {
904
+ eventType = line.slice(7).trim();
905
+ } else if (line.startsWith("data: ") && eventType) {
906
+ const data = JSON.parse(line.slice(6));
907
+ switch (eventType) {
908
+ case "token":
909
+ if (!answerStarted) {
910
+ answerStarted = true;
911
+ process.stdout.write(`
912
+ ${pc6.bold("Answer:")} `);
913
+ }
914
+ process.stdout.write(data.token);
915
+ break;
916
+ case "sources": {
917
+ if (answerStarted) process.stdout.write("\n");
918
+ console.log();
919
+ const results = data.results || [];
920
+ if (results.length > 0) {
921
+ console.log(` ${pc6.bold("Sources:")} (${results.length})`);
922
+ for (let i = 0; i < results.length; i++) {
923
+ const r = results[i];
924
+ console.log();
925
+ console.log(` ${pc6.dim(`${i + 1}.`)} ${pc6.bold(r.title || "Untitled")} ${pc6.dim(`(${r.content_id})`)}`);
926
+ if (r.chunk_text) {
927
+ console.log(` ${pc6.dim("Snippet:")} ${r.chunk_text}`);
928
+ }
929
+ }
930
+ } else {
931
+ console.log(` ${pc6.dim("No sources found.")}`);
932
+ }
933
+ console.log();
934
+ if (opts.output === "json") {
935
+ console.log(JSON.stringify(data, null, 2));
936
+ }
937
+ break;
938
+ }
939
+ case "error":
940
+ error(`Stream error: ${data.error}`);
941
+ break;
942
+ case "done":
943
+ break;
944
+ }
945
+ eventType = null;
946
+ }
947
+ }
948
+ }
949
+ } catch (err) {
950
+ error(formatApiError(err));
951
+ process.exit(1);
952
+ }
953
+ });
777
954
  }
778
955
  function outputByFormat(format, data) {
779
956
  if (format === "json") {
@@ -785,8 +962,168 @@ function outputByFormat(format, data) {
785
962
 
786
963
  // src/commands/ingest.ts
787
964
  import { createHash } from "crypto";
788
- import { readFile, stat } from "fs/promises";
965
+ import { access, readFile, stat } from "fs/promises";
789
966
  import { basename, resolve } from "path";
967
+ import * as p3 from "@clack/prompts";
968
+ import pc8 from "picocolors";
969
+
970
+ // src/lib/folder-picker.ts
971
+ import * as p2 from "@clack/prompts";
972
+ import pc7 from "picocolors";
973
+ var PAGE_SIZE = 50;
974
+ async function fetchFolders(parentId, offset, opts) {
975
+ if (parentId) {
976
+ return apiRequest({
977
+ path: `/org/kb/nodes/${parentId}/children`,
978
+ params: { type: "folder", limit: PAGE_SIZE, offset },
979
+ apiKey: opts.apiKey,
980
+ baseUrl: opts.baseUrl
981
+ });
982
+ }
983
+ return apiRequest({
984
+ path: "/org/kb/my-files",
985
+ params: { type: "folder", limit: PAGE_SIZE, offset },
986
+ apiKey: opts.apiKey,
987
+ baseUrl: opts.baseUrl
988
+ });
989
+ }
990
+ async function createFolder(name, parentId, opts) {
991
+ const body = { name };
992
+ if (parentId) body.parent_id = parentId;
993
+ try {
994
+ const data = await apiRequest({
995
+ method: "POST",
996
+ path: "/org/kb/folders",
997
+ body,
998
+ apiKey: opts.apiKey,
999
+ baseUrl: opts.baseUrl
1000
+ });
1001
+ success(`Folder "${name}" created.`);
1002
+ return { folderId: data.kb_node_id, folderName: name };
1003
+ } catch (err) {
1004
+ if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
1005
+ error(
1006
+ "Verify with the org admin that you have the proper scope to create under the org's root folder."
1007
+ );
1008
+ process.exit(1);
1009
+ }
1010
+ throw err;
1011
+ }
1012
+ }
1013
+ async function promptCreateFolder(parentId, opts) {
1014
+ const name = await p2.text({
1015
+ message: "Enter a name for your new folder:",
1016
+ validate: (val) => {
1017
+ if (!val || val.trim().length === 0) return "Folder name is required";
1018
+ }
1019
+ });
1020
+ if (p2.isCancel(name)) {
1021
+ p2.cancel("Upload cancelled.");
1022
+ process.exit(0);
1023
+ }
1024
+ return createFolder(name.trim(), parentId, opts);
1025
+ }
1026
+ async function pickFolder(opts) {
1027
+ const navigationStack = [];
1028
+ let currentParentId = null;
1029
+ let currentOffset = 0;
1030
+ let loadedFolders = [];
1031
+ let totalCount = 0;
1032
+ let needsFetch = true;
1033
+ while (true) {
1034
+ if (needsFetch) {
1035
+ const spin = p2.spinner();
1036
+ spin.start("Loading folders...");
1037
+ try {
1038
+ const response = await fetchFolders(currentParentId, currentOffset, opts);
1039
+ if (currentOffset === 0) {
1040
+ loadedFolders = response.nodes;
1041
+ } else {
1042
+ loadedFolders = [...loadedFolders, ...response.nodes];
1043
+ }
1044
+ totalCount = response.total;
1045
+ spin.stop(
1046
+ `${loadedFolders.length} folder(s) loaded${loadedFolders.length < totalCount ? ` of ${totalCount}` : ""}`
1047
+ );
1048
+ needsFetch = false;
1049
+ if (loadedFolders.length === 0 && currentParentId === null) {
1050
+ info("No folders found. Let's create one.");
1051
+ return promptCreateFolder(null, opts);
1052
+ }
1053
+ } catch (err) {
1054
+ spin.stop("Failed to load folders");
1055
+ error(formatApiError(err));
1056
+ process.exit(1);
1057
+ }
1058
+ }
1059
+ const breadcrumb = navigationStack.length === 0 ? "My Files" : "My Files > " + navigationStack.map((s) => s.name).join(" > ");
1060
+ const currentFolderName = navigationStack.length > 0 ? navigationStack[navigationStack.length - 1].name : null;
1061
+ console.log();
1062
+ console.log(` ${pc7.bold("Location:")} ${breadcrumb}`);
1063
+ console.log();
1064
+ if (currentFolderName) {
1065
+ console.log(` ${pc7.dim("Use arrow keys to navigate, Enter to select.")}`);
1066
+ console.log(` ${pc7.dim("Pick a folder to open it, or choose an action below the list.")}`);
1067
+ } else {
1068
+ console.log(` ${pc7.dim("Use arrow keys to navigate, Enter to select a folder to open it.")}`);
1069
+ }
1070
+ console.log();
1071
+ const options = [];
1072
+ for (const folder2 of loadedFolders) {
1073
+ options.push({ value: folder2.kb_node_id, label: `\u{1F4C1} ${folder2.name}` });
1074
+ }
1075
+ if (loadedFolders.length < totalCount) {
1076
+ options.push({ value: "__LOAD_MORE__", label: pc7.dim("Load more...") });
1077
+ }
1078
+ if (currentFolderName) {
1079
+ options.push({ value: "__SELECT_CURRENT__", label: pc7.green(`\u2713 Select "${currentFolderName}"`) });
1080
+ options.push({ value: "__BACK__", label: pc7.dim("\u2190 Go back") });
1081
+ }
1082
+ options.push({
1083
+ value: "__NEW_FOLDER__",
1084
+ label: pc7.cyan("+ Create new folder here")
1085
+ });
1086
+ const choice = await p2.select({
1087
+ message: "Choose a folder or action:",
1088
+ options
1089
+ });
1090
+ if (p2.isCancel(choice)) {
1091
+ p2.cancel("Upload cancelled.");
1092
+ process.exit(0);
1093
+ }
1094
+ const selected = choice;
1095
+ if (selected === "__BACK__") {
1096
+ navigationStack.pop();
1097
+ currentParentId = navigationStack.length > 0 ? navigationStack[navigationStack.length - 1].id : null;
1098
+ currentOffset = 0;
1099
+ loadedFolders = [];
1100
+ needsFetch = true;
1101
+ continue;
1102
+ }
1103
+ if (selected === "__LOAD_MORE__") {
1104
+ currentOffset += PAGE_SIZE;
1105
+ needsFetch = true;
1106
+ continue;
1107
+ }
1108
+ if (selected === "__SELECT_CURRENT__") {
1109
+ const current = navigationStack[navigationStack.length - 1];
1110
+ return { folderId: current.id, folderName: current.name };
1111
+ }
1112
+ if (selected === "__NEW_FOLDER__") {
1113
+ return promptCreateFolder(currentParentId, opts);
1114
+ }
1115
+ const folder = loadedFolders.find((f) => f.kb_node_id === selected);
1116
+ if (folder) {
1117
+ navigationStack.push({ id: folder.kb_node_id, name: folder.name });
1118
+ currentParentId = folder.kb_node_id;
1119
+ currentOffset = 0;
1120
+ loadedFolders = [];
1121
+ needsFetch = true;
1122
+ }
1123
+ }
1124
+ }
1125
+
1126
+ // src/commands/ingest.ts
790
1127
  var MIME_TYPES = {
791
1128
  ".pdf": "application/pdf",
792
1129
  ".txt": "text/plain",
@@ -834,49 +1171,98 @@ async function uploadToS3(url, buffer, contentType) {
834
1171
  }
835
1172
  function registerIngestCommands(program2) {
836
1173
  const ingest = program2.command("ingest").description("Ingest files into the knowledge base. Upload documents (PDF, TXT, DOCX, etc.) to be parsed, chunked, and embedded for semantic search.");
837
- ingest.command("upload <files...>").description("Upload files to the knowledge base. Accepts local file paths (up to 10). Files are hashed, uploaded to S3, then parsed and embedded by a background worker. Poll 'senso content get <content-id>' until processing_status is 'complete' before searching the uploaded content.").action(async (files) => {
1174
+ ingest.command("upload <files...>").description("Upload files to the knowledge base. Accepts local file paths (up to 10). Files are hashed, uploaded to S3, then parsed and embedded by a background worker. Poll 'senso content get <content-id>' until processing_status is 'complete' before searching the uploaded content.").option("--folder-id <id>", "Destination folder ID (skip interactive prompt)").action(async (files, cmdOpts) => {
838
1175
  const opts = program2.opts();
839
1176
  if (files.length > 10) {
840
1177
  error("Maximum 10 files per upload request.");
841
1178
  process.exit(1);
842
1179
  }
843
- try {
1180
+ for (const file of files) {
1181
+ try {
1182
+ await access(resolve(file));
1183
+ } catch {
1184
+ error(`File not found: "${file}". Please check the file name and try again.`);
1185
+ process.exit(1);
1186
+ }
1187
+ }
1188
+ const prepSpin = p3.spinner();
1189
+ try {
1190
+ let kbFolderNodeId;
1191
+ if (cmdOpts.folderId) {
1192
+ kbFolderNodeId = cmdOpts.folderId;
1193
+ } else if (process.stdin.isTTY) {
1194
+ const folder = await pickFolder({ apiKey: opts.apiKey, baseUrl: opts.baseUrl });
1195
+ const fileList = files.map((f) => basename(f)).join(", ");
1196
+ const answer = await p3.text({
1197
+ message: `You want to upload ${pc8.bold(`"${fileList}"`)} to the folder ${pc8.bold(pc8.cyan(`"${folder.folderName}"`))}? Type 'yes' or 'no' to continue:`,
1198
+ validate: (val) => {
1199
+ const v = val.trim().toLowerCase();
1200
+ if (v !== "yes" && v !== "no") return "Please type 'yes' or 'no'";
1201
+ }
1202
+ });
1203
+ if (p3.isCancel(answer) || answer.trim().toLowerCase() === "no") {
1204
+ p3.cancel("Upload cancelled.");
1205
+ process.exit(0);
1206
+ }
1207
+ kbFolderNodeId = folder.folderId;
1208
+ }
844
1209
  const fileData = await Promise.all(files.map(getFileMetadata));
1210
+ const emptyFiles = fileData.filter((f) => f.meta.file_size_bytes < 1);
1211
+ if (emptyFiles.length > 0) {
1212
+ for (const f of emptyFiles) {
1213
+ error(`File "${f.meta.filename}" is empty. Please select a valid file with content.`);
1214
+ }
1215
+ process.exit(1);
1216
+ }
1217
+ const body = { files: fileData.map((f) => f.meta) };
1218
+ if (kbFolderNodeId) body.kb_folder_node_id = kbFolderNodeId;
1219
+ prepSpin.start("Preparing upload...");
845
1220
  const response = await apiRequest({
846
1221
  method: "POST",
847
1222
  path: "/org/kb/upload",
848
- body: { files: fileData.map((f) => f.meta) },
1223
+ body,
849
1224
  apiKey: opts.apiKey,
850
1225
  baseUrl: opts.baseUrl
851
1226
  });
852
1227
  const items = response.results ?? [];
1228
+ const pendingCount = items.filter((i) => i.status === "upload_pending" && i.upload_url).length;
1229
+ prepSpin.stop(`${pendingCount} file(s) ready for upload`);
853
1230
  let uploaded = 0;
1231
+ const failed = [];
854
1232
  for (const item of items) {
855
1233
  if (item.status === "upload_pending" && item.upload_url) {
856
1234
  const match = fileData.find((f) => f.meta.filename === item.filename);
857
1235
  if (!match) {
858
- error(`Could not match server filename "${item.filename}" to a local file \u2014 skipping upload.`);
1236
+ failed.push({ filename: item.filename, reason: "Could not match to a local file." });
859
1237
  continue;
860
1238
  }
1239
+ const uploadSpin = p3.spinner();
1240
+ uploadSpin.start(`Uploading ${item.filename}...`);
861
1241
  try {
862
1242
  await uploadToS3(item.upload_url, match.buffer, match.meta.content_type);
863
1243
  uploaded++;
864
- success(`Uploaded ${item.filename} (content_id: ${item.content_id})`);
1244
+ uploadSpin.stop(`Uploaded ${item.filename}`);
865
1245
  } catch (uploadErr) {
866
- error(`S3 upload failed for ${item.filename}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`);
1246
+ uploadSpin.stop(`Failed to upload ${item.filename}`);
1247
+ failed.push({
1248
+ filename: item.filename,
1249
+ reason: `Upload failed: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
1250
+ });
867
1251
  }
868
1252
  } else {
869
- warn(`Skipped ${item.filename}: ${item.status}${item.error ? ` \u2014 ${item.error}` : ""}`);
1253
+ failed.push({
1254
+ filename: item.filename,
1255
+ reason: uploadStatusToReason(item.status, item.error)
1256
+ });
870
1257
  }
871
1258
  }
872
- if (uploaded > 0) {
873
- success(`${uploaded} file(s) uploaded. Background processing will parse, chunk, and embed them.`);
874
- }
1259
+ printUploadSummary(uploaded, failed, items);
875
1260
  if (opts.output === "json") {
876
1261
  console.log(JSON.stringify(response, null, 2));
877
1262
  }
878
1263
  } catch (err) {
879
- error(formatApiError(err));
1264
+ prepSpin.stop("Upload failed");
1265
+ handleUploadError(err);
880
1266
  process.exit(1);
881
1267
  }
882
1268
  });
@@ -908,7 +1294,7 @@ function registerIngestCommands(program2) {
908
1294
  }
909
1295
 
910
1296
  // src/commands/content.ts
911
- import pc6 from "picocolors";
1297
+ import pc9 from "picocolors";
912
1298
  function registerContentCommands(program2) {
913
1299
  const content = program2.command("content").description("Manage content items in the knowledge base. List, inspect, delete, unpublish, and manage the verification workflow and ownership of content.");
914
1300
  content.command("list").description("List top-level files and folders in the knowledge base. Use 'kb my-files' for the same result with richer KB node output.").option("--limit <n>", "Items per page", "10").option("--offset <n>", "Pagination offset", "0").action(async (cmdOpts) => {
@@ -934,7 +1320,7 @@ function registerContentCommands(program2) {
934
1320
  columns: ["id", "name", "type", "status"]
935
1321
  },
936
1322
  plain: rows.length ? rows.map(
937
- (r) => ` ${pc6.bold(String(r.name || "Untitled"))} ${pc6.dim(`(${r.kb_node_id})`)} ${r.type ? pc6.dim(`[${r.type}]`) : ""}`
1323
+ (r) => ` ${pc9.bold(String(r.name || "Untitled"))} ${pc9.dim(`(${r.kb_node_id})`)} ${r.type ? pc9.dim(`[${r.type}]`) : ""}`
938
1324
  ) : [" No content found."]
939
1325
  });
940
1326
  } catch (err) {
@@ -1047,6 +1433,8 @@ function registerContentCommands(program2) {
1047
1433
  }
1048
1434
 
1049
1435
  // src/commands/generate.ts
1436
+ var SAMPLE_JOB_POLL_INTERVAL_MS = 2e3;
1437
+ var SAMPLE_JOB_TIMEOUT_MS = 18e4;
1050
1438
  function registerGenerateCommands(program2) {
1051
1439
  const gen = program2.command("generate").description("AI content generation. Configure settings, generate content samples from prompts, or trigger full content engine runs.");
1052
1440
  gen.command("settings").description("Get content generation settings. Shows whether generation and auto-publish are enabled, the content schedule, and configured publishers.").action(async () => {
@@ -1071,7 +1459,7 @@ function registerGenerateCommands(program2) {
1071
1459
  process.exit(1);
1072
1460
  }
1073
1461
  });
1074
- gen.command("sample").description("Generate an ad hoc content sample for a specific prompt and content type. Returns the generated markdown, SEO title, and publish results. Use 'prompts list' to find a prompt ID, and 'content-types list' to find a content-type ID.").requiredOption("--prompt-id <id>", "Prompt (geo question) ID to generate content for").requiredOption("--content-type-id <id>", "Content type ID that defines the output format (use 'content-types list' to find)").option("--destination <dest>", "Publisher slug to publish to immediately after generation. Omit to save as draft only.").action(async (cmdOpts) => {
1462
+ gen.command("sample").description("Generate an ad hoc content sample for a specific prompt and content type. Submits an async job, waits for completion by default, then returns the generated markdown, SEO title, and publish results. Use 'prompts list' to find a prompt ID, and 'content-types list' to find a content-type ID.").requiredOption("--prompt-id <id>", "Prompt (geo question) ID to generate content for").requiredOption("--content-type-id <id>", "Content type ID that defines the output format (use 'content-types list' to find)").option("--destination <dest>", "Publisher slug to publish to immediately after generation. Omit to save as draft only.").option("--no-wait", "Return the accepted sample job immediately instead of polling for the generated content.").action(async (cmdOpts) => {
1075
1463
  const opts = program2.opts();
1076
1464
  try {
1077
1465
  const body = {
@@ -1088,7 +1476,25 @@ function registerGenerateCommands(program2) {
1088
1476
  apiKey: opts.apiKey,
1089
1477
  baseUrl: opts.baseUrl
1090
1478
  });
1091
- console.log(JSON.stringify(data, null, 2));
1479
+ if (cmdOpts.wait === false) {
1480
+ console.log(JSON.stringify(data, null, 2));
1481
+ return;
1482
+ }
1483
+ const quiet = opts.quiet || opts.output === "json";
1484
+ if (!quiet) {
1485
+ info(`Sample job accepted: ${data.sample_job_id}`);
1486
+ }
1487
+ const job = await waitForSampleJob(data.sample_job_id, {
1488
+ apiKey: opts.apiKey,
1489
+ baseUrl: opts.baseUrl,
1490
+ quiet
1491
+ });
1492
+ if (job.status === "completed") {
1493
+ console.log(JSON.stringify(job.result ?? job, null, 2));
1494
+ return;
1495
+ }
1496
+ const message = job.error?.message || `Sample job ended with status: ${job.status}`;
1497
+ throw new Error(job.error?.code ? `${message} (${job.error.code})` : message);
1092
1498
  } catch (err) {
1093
1499
  error(formatApiError(err));
1094
1500
  process.exit(1);
@@ -1182,6 +1588,29 @@ function registerGenerateCommands(program2) {
1182
1588
  }
1183
1589
  });
1184
1590
  }
1591
+ async function waitForSampleJob(sampleJobId, opts) {
1592
+ const deadline = Date.now() + SAMPLE_JOB_TIMEOUT_MS;
1593
+ let lastStatus = "";
1594
+ while (Date.now() < deadline) {
1595
+ const job = await apiRequest({
1596
+ path: `/org/content-generation/sample-jobs/${sampleJobId}`,
1597
+ apiKey: opts.apiKey,
1598
+ baseUrl: opts.baseUrl
1599
+ });
1600
+ if (!opts.quiet && job.status !== lastStatus) {
1601
+ info(`Sample job status: ${job.status}`);
1602
+ lastStatus = job.status;
1603
+ }
1604
+ if (job.status === "completed" || job.status === "failed" || job.status === "expired") {
1605
+ return job;
1606
+ }
1607
+ await sleep(SAMPLE_JOB_POLL_INTERVAL_MS);
1608
+ }
1609
+ throw new Error(`Timed out waiting for sample job ${sampleJobId}. Poll /org/content-generation/sample-jobs/${sampleJobId} for status.`);
1610
+ }
1611
+ function sleep(ms) {
1612
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1613
+ }
1185
1614
 
1186
1615
  // src/commands/engine.ts
1187
1616
  function registerEngineCommands(program2) {
@@ -1781,12 +2210,12 @@ function registerKBCommands(program2) {
1781
2210
  process.exit(1);
1782
2211
  }
1783
2212
  });
1784
- kb.command("my-files").description("List top-level files and folders in the knowledge base.").option("--limit <n>", "Items per page", "50").option("--offset <n>", "Pagination offset", "0").action(async (cmdOpts) => {
2213
+ kb.command("my-files").description("List top-level files and folders in the knowledge base.").option("--limit <n>", "Items per page", "50").option("--offset <n>", "Pagination offset", "0").option("--type <type>", "Filter by node type (folder or content)").action(async (cmdOpts) => {
1785
2214
  const opts = program2.opts();
1786
2215
  try {
1787
2216
  const data = await apiRequest({
1788
2217
  path: "/org/kb/my-files",
1789
- params: { limit: cmdOpts.limit, offset: cmdOpts.offset },
2218
+ params: { limit: cmdOpts.limit, offset: cmdOpts.offset, type: cmdOpts.type },
1790
2219
  apiKey: opts.apiKey,
1791
2220
  baseUrl: opts.baseUrl
1792
2221
  });
@@ -1796,12 +2225,12 @@ function registerKBCommands(program2) {
1796
2225
  process.exit(1);
1797
2226
  }
1798
2227
  });
1799
- kb.command("find").description("Search KB nodes by name.").requiredOption("--query <q>", "Name search query").option("--limit <n>", "Items per page", "20").option("--offset <n>", "Pagination offset", "0").action(async (cmdOpts) => {
2228
+ kb.command("find").description("Search KB nodes by name.").requiredOption("--query <q>", "Name search query").option("--limit <n>", "Items per page", "20").option("--offset <n>", "Pagination offset", "0").option("--type <type>", "Filter by node type (folder or content)").action(async (cmdOpts) => {
1800
2229
  const opts = program2.opts();
1801
2230
  try {
1802
2231
  const data = await apiRequest({
1803
2232
  path: "/org/kb/find",
1804
- params: { q: cmdOpts.query, limit: cmdOpts.limit, offset: cmdOpts.offset },
2233
+ params: { q: cmdOpts.query, limit: cmdOpts.limit, offset: cmdOpts.offset, type: cmdOpts.type },
1805
2234
  apiKey: opts.apiKey,
1806
2235
  baseUrl: opts.baseUrl
1807
2236
  });
@@ -1831,12 +2260,12 @@ function registerKBCommands(program2) {
1831
2260
  process.exit(1);
1832
2261
  }
1833
2262
  });
1834
- kb.command("children <id>").description("List children of a KB folder node.").option("--limit <n>", "Items per page", "50").option("--offset <n>", "Pagination offset", "0").action(async (id, cmdOpts) => {
2263
+ kb.command("children <id>").description("List children of a KB folder node.").option("--limit <n>", "Items per page", "50").option("--offset <n>", "Pagination offset", "0").option("--type <type>", "Filter by node type (folder or content)").action(async (id, cmdOpts) => {
1835
2264
  const opts = program2.opts();
1836
2265
  try {
1837
2266
  const data = await apiRequest({
1838
2267
  path: `/org/kb/nodes/${id}/children`,
1839
- params: { limit: cmdOpts.limit, offset: cmdOpts.offset },
2268
+ params: { limit: cmdOpts.limit, offset: cmdOpts.offset, type: cmdOpts.type },
1840
2269
  apiKey: opts.apiKey,
1841
2270
  baseUrl: opts.baseUrl
1842
2271
  });
@@ -1986,30 +2415,34 @@ function registerKBCommands(program2) {
1986
2415
  });
1987
2416
  const items = response.results ?? [];
1988
2417
  let uploaded = 0;
2418
+ const failed = [];
1989
2419
  for (const item of items) {
1990
2420
  if (item.status === "upload_pending" && item.upload_url) {
1991
2421
  const match = fileData.find((f) => f.meta.filename === item.filename);
1992
2422
  if (!match) {
1993
- error(`Could not match server filename "${item.filename}" to a local file \u2014 skipping upload.`);
2423
+ failed.push({ filename: item.filename, reason: "Could not match to a local file." });
1994
2424
  continue;
1995
2425
  }
1996
2426
  try {
1997
2427
  await uploadToS32(item.upload_url, match.buffer, match.meta.content_type);
1998
2428
  uploaded++;
1999
- success(`Uploaded ${item.filename} (content_id: ${item.content_id})`);
2000
2429
  } catch (uploadErr) {
2001
- error(`S3 upload failed for ${item.filename}: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`);
2430
+ failed.push({
2431
+ filename: item.filename,
2432
+ reason: `Upload failed: ${uploadErr instanceof Error ? uploadErr.message : String(uploadErr)}`
2433
+ });
2002
2434
  }
2003
2435
  } else {
2004
- warn(`Skipped ${item.filename}: ${item.status}${item.error ? ` \u2014 ${item.error}` : ""}`);
2436
+ failed.push({
2437
+ filename: item.filename,
2438
+ reason: uploadStatusToReason(item.status, item.error)
2439
+ });
2005
2440
  }
2006
2441
  }
2007
- if (uploaded > 0) {
2008
- success(`${uploaded} file(s) uploaded. Background processing will parse, chunk, and embed them.`);
2009
- }
2442
+ printUploadSummary(uploaded, failed, items);
2010
2443
  if (opts.output === "json") console.log(JSON.stringify(response, null, 2));
2011
2444
  } catch (err) {
2012
- error(formatApiError(err));
2445
+ handleUploadError(err);
2013
2446
  process.exit(1);
2014
2447
  }
2015
2448
  });
@@ -2055,12 +2488,12 @@ function registerPermissionsCommands(program2) {
2055
2488
 
2056
2489
  // src/commands/update.ts
2057
2490
  import semver2 from "semver";
2058
- import pc7 from "picocolors";
2491
+ import pc10 from "picocolors";
2059
2492
  import { execSync } from "child_process";
2060
2493
  var NPM_PACKAGE2 = "@senso-ai/cli";
2061
2494
  function registerUpdateCommand(program2) {
2062
2495
  program2.command("update").description("Update CLI to the latest version").action(async () => {
2063
- info(`Current version: ${pc7.bold(version)}`);
2496
+ info(`Current version: ${pc10.bold(version)}`);
2064
2497
  info("Checking npm for updates...");
2065
2498
  const latest = await getLatestVersion();
2066
2499
  if (!latest) {
@@ -2071,7 +2504,7 @@ function registerUpdateCommand(program2) {
2071
2504
  success(`Already on the latest version (${version}).`);
2072
2505
  return;
2073
2506
  }
2074
- info(`New version available: ${pc7.bold(latest)}`);
2507
+ info(`New version available: ${pc10.bold(latest)}`);
2075
2508
  info("Updating...");
2076
2509
  try {
2077
2510
  execSync(`npm install -g ${NPM_PACKAGE2}@latest`, {
@@ -2081,7 +2514,7 @@ function registerUpdateCommand(program2) {
2081
2514
  } catch {
2082
2515
  error("Update failed. Please reinstall manually:");
2083
2516
  console.log(
2084
- ` ${pc7.cyan(`npm install -g ${NPM_PACKAGE2}`)}`
2517
+ ` ${pc10.cyan(`npm install -g ${NPM_PACKAGE2}`)}`
2085
2518
  );
2086
2519
  process.exit(1);
2087
2520
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senso-ai/cli",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Senso CLI — Infrastructure for the Agentic Web. Interact with your Senso knowledge base from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {