@senso-ai/cli 0.14.0 → 0.15.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 +290 -16
  3. package/package.json +4 -1
package/README.md CHANGED
@@ -118,7 +118,7 @@ argument and flag, generated from the CLI itself.
118
118
  | ------------------------------------------------------------------------ | ------------------------------------------------------------------- |
119
119
  | `login` `logout` `whoami` | Authentication and the current organization |
120
120
  | `search` | Ask the knowledge base, with an AI answer, raw chunks, or streaming |
121
- | `kb` `ingest` `content` | The knowledge base: upload, browse, organize, verify |
121
+ | `kb` `ingest` `content` `website-import` | The knowledge base: upload, browse, organize, verify |
122
122
  | `ctas` | Call-to-action cards on published pages |
123
123
  | `generate` `engine` `generated-content` `destinations` `publish-records` | Generate content and publish it |
124
124
  | `analytics` | GEO metrics for your own organization |
package/dist/cli.js CHANGED
@@ -201,6 +201,9 @@ var ApiError = class extends Error {
201
201
  this.body = body;
202
202
  this.name = "ApiError";
203
203
  }
204
+ status;
205
+ statusText;
206
+ body;
204
207
  };
205
208
  function extractErrorMessage(body, fallback) {
206
209
  if (typeof body !== "object" || !body) return fallback;
@@ -934,10 +937,10 @@ function registerOrgCommands(program) {
934
937
  })
935
938
  );
936
939
  org.command("update").description(
937
- "Update organization details. All fields are optional \u2014 only provided fields are changed. Pass an empty array for websites/locations to clear them."
940
+ "Update organization details. Only the fields you pass are changed; omitting a field leaves it alone. But 'websites' and 'locations' REPLACE their whole list when passed \u2014 sending one website deletes the rest. To add to either list, run 'org get' first and send back every entry you want to keep."
938
941
  ).requiredOption(
939
942
  "--data <json>",
940
- 'JSON: { "name": "...", "slug": "...", "logo_url": "...", "websites": [...], "locations": [...] }'
943
+ `JSON: { "name": "Acme", "slug": "acme", "logo_url": "https://acme.com/logo.png", "websites": [{"url": "https://acme.com"}], "locations": [{"country_code": "US", "region_name": "California"}] }. Every field is optional. "websites" and "locations" REPLACE the existing list rather than adding to it \u2014 include every entry you want to keep, or pass [] to clear the list. A website entry takes only "url"; sending the "org_website_id" from 'org get' is rejected. Send "logo_url": "" to clear the logo.`
941
944
  ).action(
942
945
  runAction(program, async (ctx, cmdOpts) => {
943
946
  const body = parseJsonFlag(cmdOpts.data);
@@ -1769,7 +1772,7 @@ function registerIngestCommands(program) {
1769
1772
  "Ingest files into the knowledge base. Upload documents (PDF, TXT, DOCX, etc.) to be parsed, chunked, and embedded for semantic search."
1770
1773
  );
1771
1774
  ingest.command("upload <files...>").description(
1772
- "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."
1775
+ "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 kb get <kb-node-id>' until content.processing_status is 'complete' before searching the uploaded content."
1773
1776
  ).option("--folder-id <id>", "Destination folder ID (skip interactive prompt)").action(
1774
1777
  runAction(program, async (ctx, files, cmdOpts) => {
1775
1778
  if (files.length > 10) {
@@ -1868,9 +1871,13 @@ function registerIngestCommands(program) {
1868
1871
  rows: items.map((i) => ({
1869
1872
  filename: i.filename,
1870
1873
  status: i.status,
1874
+ // The id `senso kb get` takes. `content_id` is not interchangeable
1875
+ // with it: polling a KB upload through `senso content get` hits an
1876
+ // endpoint that serves non-KB content only and answers 400.
1877
+ kb_node_id: i.kb_node_id,
1871
1878
  content_id: i.content_id
1872
1879
  })),
1873
- columns: ["filename", "status", "content_id"]
1880
+ columns: ["filename", "status", "kb_node_id", "content_id"]
1874
1881
  },
1875
1882
  plain: []
1876
1883
  });
@@ -1904,6 +1911,159 @@ function registerIngestCommands(program) {
1904
1911
  );
1905
1912
  }
1906
1913
 
1914
+ // src/commands/website-import.ts
1915
+ var POLL_INTERVAL_MS = 2e3;
1916
+ var IMPORT_TIMEOUT_MS = 18e4;
1917
+ var RUN_COLUMNS = [
1918
+ "run_id",
1919
+ "status",
1920
+ "source_url",
1921
+ "pages_fetched",
1922
+ "pages_ingested",
1923
+ "brand_kit_generated"
1924
+ ];
1925
+ function getStatus(ctx) {
1926
+ return apiRequest({
1927
+ path: "/org/website-import/status",
1928
+ apiKey: ctx.apiKey,
1929
+ baseUrl: ctx.baseUrl
1930
+ });
1931
+ }
1932
+ function sleep(ms) {
1933
+ return new Promise((resolve4) => setTimeout(resolve4, ms));
1934
+ }
1935
+ async function waitForImport(runId, ctx) {
1936
+ const deadline = Date.now() + IMPORT_TIMEOUT_MS;
1937
+ let lastStatus = "";
1938
+ while (Date.now() < deadline) {
1939
+ const { current, latest_completed: latest } = await getStatus(ctx);
1940
+ if (current && !ctx.quiet && current.status !== lastStatus) {
1941
+ info(`Website import status: ${current.status}`);
1942
+ lastStatus = current.status;
1943
+ }
1944
+ if (!current) {
1945
+ if (latest?.run_id === runId) return latest;
1946
+ throw new CliError(
1947
+ `Website import ${runId} finished but its result could not be read.`,
1948
+ EXIT.ERROR,
1949
+ {
1950
+ hint: "Run `senso website-import status` to see the most recent import."
1951
+ }
1952
+ );
1953
+ }
1954
+ await sleep(POLL_INTERVAL_MS);
1955
+ }
1956
+ throw new CliError(`Timed out waiting for website import ${runId}.`, EXIT.NETWORK, {
1957
+ code: "timeout",
1958
+ hint: "Run `senso website-import status` to check on it."
1959
+ });
1960
+ }
1961
+ function describeOutcome(run, quiet) {
1962
+ if (quiet) return;
1963
+ if (run.status === "failed") {
1964
+ error(`Website import failed: ${run.error_message ?? "no reason given"}`);
1965
+ if (run.error_code) error(`Reason code: ${run.error_code}`);
1966
+ return;
1967
+ }
1968
+ success(
1969
+ `Imported ${String(run.pages_ingested)} of ${String(run.pages_fetched)} page(s) from ${run.source_url}.`
1970
+ );
1971
+ if (run.brand_kit_generated) {
1972
+ success("A brand kit was generated from the site.");
1973
+ } else if (run.brand_kit_skip_reason === "already_populated") {
1974
+ info("Brand kit left alone \u2014 this organization already has one.");
1975
+ }
1976
+ if (run.pages_ingested < run.pages_fetched) {
1977
+ info("Pages already in the knowledge base unchanged are fetched but not re-ingested.");
1978
+ }
1979
+ }
1980
+ function buildStatusRows(data) {
1981
+ const rows = [];
1982
+ if (data.current) rows.push({ slot: "current", ...data.current });
1983
+ if (data.latest_completed) rows.push({ slot: "latest_completed", ...data.latest_completed });
1984
+ return rows;
1985
+ }
1986
+ function registerWebsiteImportCommands(program) {
1987
+ const websiteImport = program.command("website-import").description(
1988
+ "Import your organization's website into the knowledge base. Fetches the home page plus up to 10 linked pages, ingests each as a document under a folder named 'Website', and drafts a brand kit if the organization does not have one yet."
1989
+ );
1990
+ websiteImport.command("start").description(
1991
+ "Start a website import and wait for it to finish. The site imported is the one on file for your organization \u2014 see 'senso org get' \u2014 not a value you pass, so this takes no arguments. Exits 1 if the import finishes in a failed state."
1992
+ ).option(
1993
+ "--no-wait",
1994
+ "Return the accepted run immediately instead of polling until the import finishes."
1995
+ ).action(
1996
+ runAction(program, async (ctx, cmdOpts) => {
1997
+ let accepted;
1998
+ try {
1999
+ accepted = await apiRequest({
2000
+ method: "POST",
2001
+ path: "/org/website-import",
2002
+ apiKey: ctx.apiKey,
2003
+ baseUrl: ctx.baseUrl
2004
+ });
2005
+ } catch (err) {
2006
+ const status = err.status;
2007
+ if (status === 409) {
2008
+ throw new CliError(
2009
+ "A website import is already running for this organization.",
2010
+ EXIT.ERROR,
2011
+ {
2012
+ code: "conflict",
2013
+ status: 409,
2014
+ hint: "Run `senso website-import status` to follow the one in flight.",
2015
+ cause: err
2016
+ }
2017
+ );
2018
+ }
2019
+ throw err;
2020
+ }
2021
+ if (!cmdOpts.wait) {
2022
+ if (!ctx.quiet) success(`Website import ${accepted.run_id} started.`);
2023
+ emit(ctx, accepted, { columns: RUN_COLUMNS });
2024
+ return;
2025
+ }
2026
+ const spin = spinner4(ctx.quiet);
2027
+ spin.start("Importing website...");
2028
+ let finished;
2029
+ try {
2030
+ finished = await waitForImport(accepted.run_id, ctx);
2031
+ } catch (err) {
2032
+ spin.stop("Website import did not finish");
2033
+ throw err;
2034
+ }
2035
+ spin.stop("Website import finished");
2036
+ describeOutcome(finished, ctx.quiet);
2037
+ if (finished.status === "failed") {
2038
+ throw new CliError(
2039
+ `Website import ${finished.run_id} failed${finished.error_code ? ` (${finished.error_code})` : ""}.`,
2040
+ EXIT.ERROR,
2041
+ { hint: "Run `senso website-import status` for the full record." }
2042
+ );
2043
+ }
2044
+ emit(ctx, finished, { columns: RUN_COLUMNS });
2045
+ })
2046
+ );
2047
+ websiteImport.command("status").description(
2048
+ "Show the website import in flight and the most recently finished one. Either may be absent. This is a read: it exits 0 even when the last import failed."
2049
+ ).action(
2050
+ runAction(program, async (ctx) => {
2051
+ const data = await getStatus(ctx);
2052
+ if (!ctx.quiet && !data.current && !data.latest_completed) {
2053
+ info("This organization has never imported its website.");
2054
+ }
2055
+ emit(ctx, data, {
2056
+ // A status envelope is two nullable runs, not a list, so the table
2057
+ // rendering is built here rather than left to findRows.
2058
+ table: {
2059
+ rows: buildStatusRows(data),
2060
+ columns: ["slot", ...RUN_COLUMNS]
2061
+ }
2062
+ });
2063
+ })
2064
+ );
2065
+ }
2066
+
1907
2067
  // src/commands/content.ts
1908
2068
  import pc9 from "picocolors";
1909
2069
 
@@ -2716,14 +2876,14 @@ async function waitForSampleJob(sampleJobId, opts) {
2716
2876
  if (job.status === "completed" || job.status === "failed" || job.status === "expired") {
2717
2877
  return job;
2718
2878
  }
2719
- await sleep(SAMPLE_JOB_POLL_INTERVAL_MS);
2879
+ await sleep2(SAMPLE_JOB_POLL_INTERVAL_MS);
2720
2880
  }
2721
2881
  throw new CliError(`Timed out waiting for sample job ${sampleJobId}.`, EXIT.NETWORK, {
2722
2882
  code: "timeout",
2723
2883
  hint: `Poll /org/content-generation/sample-jobs/${sampleJobId} for status.`
2724
2884
  });
2725
2885
  }
2726
- function sleep(ms) {
2886
+ function sleep2(ms) {
2727
2887
  return new Promise((resolve4) => setTimeout(resolve4, ms));
2728
2888
  }
2729
2889
 
@@ -2893,11 +3053,122 @@ function registerPublishRecordsCommands(program) {
2893
3053
  }
2894
3054
 
2895
3055
  // src/commands/brand-kit.ts
3056
+ var GUIDELINE_STRING_FIELDS = [
3057
+ "brand_name",
3058
+ "brand_domain",
3059
+ "brand_description",
3060
+ "voice_and_tone",
3061
+ "author_persona"
3062
+ ];
3063
+ var GUIDELINE_FIELDS = [...GUIDELINE_STRING_FIELDS, "global_writing_rules"];
3064
+ var FIELD_LIST = GUIDELINE_FIELDS.join(", ");
3065
+ var SET_EXAMPLE = `--data '{"guidelines":{"brand_name":"Acme","voice_and_tone":"Warm and direct"}}'`;
3066
+ function describe(value) {
3067
+ if (value === null) return "null";
3068
+ if (Array.isArray(value)) return "an array";
3069
+ return `a ${typeof value}`;
3070
+ }
3071
+ function nearest(key, allowed) {
3072
+ let best;
3073
+ let bestDistance = 3;
3074
+ for (const candidate of allowed) {
3075
+ const distance = editDistance(key, candidate);
3076
+ if (distance < bestDistance) {
3077
+ bestDistance = distance;
3078
+ best = candidate;
3079
+ }
3080
+ }
3081
+ return best;
3082
+ }
3083
+ function editDistance(a, b) {
3084
+ let row = Array.from({ length: b.length + 1 }, (_, i) => i);
3085
+ for (let i = 1; i <= a.length; i++) {
3086
+ const next = [i];
3087
+ for (let j = 1; j <= b.length; j++) {
3088
+ next.push(
3089
+ Math.min(
3090
+ (row[j] ?? 0) + 1,
3091
+ (next[j - 1] ?? 0) + 1,
3092
+ (row[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
3093
+ )
3094
+ );
3095
+ }
3096
+ row = next;
3097
+ }
3098
+ return row[b.length] ?? 0;
3099
+ }
3100
+ function usageError(message, hint2) {
3101
+ return new CliError(message, EXIT.USAGE, { code: "usage", hint: hint2 });
3102
+ }
3103
+ function parseBrandKitData(data, mode) {
3104
+ const body = parseJsonFlag(data);
3105
+ if (!("guidelines" in body)) {
3106
+ throw usageError(
3107
+ `--data must have a "guidelines" object at the top level.`,
3108
+ `The fields go inside it: ${SET_EXAMPLE}. Accepted fields: ${FIELD_LIST}.`
3109
+ );
3110
+ }
3111
+ const stray = Object.keys(body).filter((key) => key !== "guidelines");
3112
+ if (stray.length > 0) {
3113
+ throw usageError(
3114
+ `--data has ${stray.length === 1 ? "a key" : "keys"} outside "guidelines": ${stray.join(", ")}.`,
3115
+ `Move ${stray.length === 1 ? "it" : "them"} inside "guidelines" \u2014 the API ignores anything beside it without reporting that it did. Accepted fields: ${FIELD_LIST}.`
3116
+ );
3117
+ }
3118
+ const guidelines = body.guidelines;
3119
+ if (typeof guidelines !== "object" || guidelines === null || Array.isArray(guidelines)) {
3120
+ throw usageError(
3121
+ `"guidelines" must be a JSON object, not ${describe(guidelines)}.`,
3122
+ `Example: ${SET_EXAMPLE}`
3123
+ );
3124
+ }
3125
+ const entries = Object.entries(guidelines);
3126
+ if (mode === "merge" && entries.length === 0) {
3127
+ throw usageError(
3128
+ `"guidelines" must name at least one field to patch.`,
3129
+ `Accepted fields: ${FIELD_LIST}. To clear the whole brand kit instead, run: senso brand-kit set --data '{"guidelines":{}}'`
3130
+ );
3131
+ }
3132
+ for (const [key, value] of entries) {
3133
+ if (!GUIDELINE_FIELDS.includes(key)) {
3134
+ const suggestion = nearest(key, GUIDELINE_FIELDS);
3135
+ throw usageError(
3136
+ `"guidelines" does not accept the field "${key}".`,
3137
+ suggestion ? `Did you mean "${suggestion}"? Accepted fields: ${FIELD_LIST}.` : `Accepted fields: ${FIELD_LIST}.`
3138
+ );
3139
+ }
3140
+ if (key === "global_writing_rules") {
3141
+ if (!Array.isArray(value)) {
3142
+ throw usageError(
3143
+ `"global_writing_rules" must be an array of strings, not ${describe(value)}.`,
3144
+ `Example: {"global_writing_rules":["Avoid superlatives unless backed by a number"]}. Pass [] to clear the rules.`
3145
+ );
3146
+ }
3147
+ const badIndex = value.findIndex((rule) => typeof rule !== "string");
3148
+ if (badIndex !== -1) {
3149
+ throw usageError(
3150
+ `"global_writing_rules[${String(badIndex)}]" must be a string, not ${describe(value[badIndex])}.`,
3151
+ `Every rule is a line of guidance for the AI writer, e.g. "Prefer concrete examples over abstract claims".`
3152
+ );
3153
+ }
3154
+ continue;
3155
+ }
3156
+ if (typeof value !== "string") {
3157
+ throw usageError(
3158
+ `"${key}" must be a string, not ${describe(value)}.`,
3159
+ value === null ? `No field may be null. Omit it to leave it unchanged, or use 'brand-kit set' without it to remove it.` : `Example: {"${key}":"..."}`
3160
+ );
3161
+ }
3162
+ }
3163
+ return body;
3164
+ }
2896
3165
  function registerBrandKitCommands(program) {
2897
3166
  const bk = program.command("brand-kit").description(
2898
- "Manage the organization's brand kit guidelines that inform AI content generation about your brand voice, tone, and style. The guidelines object accepts a defined set of keys: brand_name, brand_domain, brand_description, voice_and_tone, author_persona, and global_writing_rules (array). Unknown keys are rejected."
3167
+ `Manage the organization's brand kit guidelines that inform AI content generation about your brand voice, tone, and style. The guidelines object accepts a defined set of keys: ${FIELD_LIST} (global_writing_rules is an array of strings, the rest are strings). Unknown keys, wrong types and nulls are rejected before the request is sent.`
2899
3168
  );
2900
- bk.command("get").description("Get the current brand kit guidelines.").action(
3169
+ bk.command("get").description(
3170
+ "Get the current brand kit guidelines. An organization that has never saved one gets an empty guidelines object rather than an error."
3171
+ ).action(
2901
3172
  runAction(program, async (ctx) => {
2902
3173
  const data = await apiRequest({
2903
3174
  path: "/org/brand-kit",
@@ -2908,13 +3179,13 @@ function registerBrandKitCommands(program) {
2908
3179
  })
2909
3180
  );
2910
3181
  bk.command("set").description(
2911
- "Replace the entire brand kit (PUT). All existing fields are overwritten \u2014 run 'brand-kit get' first to preserve fields you are not changing. For a safe partial update, use 'brand-kit patch'."
3182
+ "Replace the entire brand kit (PUT). All existing fields are overwritten \u2014 run 'brand-kit get' first to preserve fields you are not changing. For a safe partial update, use 'brand-kit patch'. This is also what creates the brand kit the first time."
2912
3183
  ).requiredOption(
2913
3184
  "--data <json>",
2914
- 'JSON: { "guidelines": { "brand_name": "Acme", "voice_and_tone": "...", "author_persona": "...", "global_writing_rules": [] } }'
3185
+ `JSON: { "guidelines": { "brand_name": "Acme", "brand_domain": "https://acme.com", "brand_description": "...", "voice_and_tone": "...", "author_persona": "...", "global_writing_rules": ["..."] } }. Every field is optional, but anything you omit is REMOVED \u2014 pass '{"guidelines":{}}' to clear the brand kit entirely.`
2915
3186
  ).action(
2916
3187
  runAction(program, async (ctx, cmdOpts) => {
2917
- const body = parseJsonFlag(cmdOpts.data);
3188
+ const body = parseBrandKitData(cmdOpts.data, "replace");
2918
3189
  const data = await apiRequest({
2919
3190
  method: "PUT",
2920
3191
  path: "/org/brand-kit",
@@ -2927,13 +3198,13 @@ function registerBrandKitCommands(program) {
2927
3198
  })
2928
3199
  );
2929
3200
  bk.command("patch").description(
2930
- "Partially update the brand kit (PATCH). Only the fields you provide are changed \u2014 existing fields are preserved. Preferred over 'set' for targeted updates."
3201
+ "Partially update the brand kit (PATCH). Only the fields you provide are changed \u2014 existing fields are preserved. Preferred over 'set' for targeted updates. Note that global_writing_rules is replaced wholesale, not appended to, and no field can be removed this way \u2014 use 'set' for that."
2931
3202
  ).requiredOption(
2932
3203
  "--data <json>",
2933
- 'JSON: { "guidelines": { "voice_and_tone": "Warm and approachable" } }'
3204
+ `JSON: { "guidelines": { "voice_and_tone": "Warm and approachable" } }. At least one field is required; accepted fields are ${FIELD_LIST}.`
2934
3205
  ).action(
2935
3206
  runAction(program, async (ctx, cmdOpts) => {
2936
- const body = parseJsonFlag(cmdOpts.data);
3207
+ const body = parseBrandKitData(cmdOpts.data, "merge");
2937
3208
  const data = await apiRequest({
2938
3209
  method: "PATCH",
2939
3210
  path: "/org/brand-kit",
@@ -3947,7 +4218,7 @@ function registerKBCommands(program) {
3947
4218
  })
3948
4219
  );
3949
4220
  kb.command("upload <files...>").description(
3950
- "Upload files to the knowledge base (up to 10). Files are hashed, uploaded to S3, then parsed and embedded by a background worker."
4221
+ "Upload files to the knowledge base (up to 10). Files are hashed, uploaded to S3, then parsed and embedded by a background worker. Poll 'senso kb get <kb-node-id>' until content.processing_status is 'complete' before searching the uploaded content."
3951
4222
  ).option("--folder-id <id>", "Parent folder node ID to place files in (omit for root)").action(
3952
4223
  runAction(program, async (ctx, files, cmdOpts) => {
3953
4224
  if (files.length > 10) {
@@ -4010,7 +4281,9 @@ function registerKBCommands(program) {
4010
4281
  });
4011
4282
  }
4012
4283
  if (ctx.format !== "plain") {
4013
- emit(ctx, response, { columns: ["filename", "status", "content_id", "error"] });
4284
+ emit(ctx, response, {
4285
+ columns: ["filename", "status", "kb_node_id", "content_id", "error"]
4286
+ });
4014
4287
  }
4015
4288
  })
4016
4289
  );
@@ -5702,6 +5975,7 @@ function createProgram() {
5702
5975
  registerApiKeyCommands(program);
5703
5976
  registerSearchCommands(program);
5704
5977
  registerIngestCommands(program);
5978
+ registerWebsiteImportCommands(program);
5705
5979
  registerContentCommands(program);
5706
5980
  registerCtaCommands(program);
5707
5981
  registerGenerateCommands(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@senso-ai/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Senso CLI — Infrastructure for the Agentic Web. Manage your Senso knowledge base, content and GEO analytics from the terminal.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -76,5 +76,8 @@
76
76
  "homepage": "https://github.com/AI-Template-SDK/senso-user-cli#readme",
77
77
  "bugs": {
78
78
  "url": "https://github.com/AI-Template-SDK/senso-user-cli/issues"
79
+ },
80
+ "overrides": {
81
+ "esbuild": "^0.28.2"
79
82
  }
80
83
  }