kenso 0.1.16 → 0.1.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/kenso.js CHANGED
@@ -2397,7 +2397,7 @@ var VERSION;
2397
2397
  var init_version = __esm({
2398
2398
  "src/core/version.ts"() {
2399
2399
  "use strict";
2400
- VERSION = true ? "0.1.16" : "0.0.0-dev";
2400
+ VERSION = true ? "0.1.17" : "0.0.0-dev";
2401
2401
  }
2402
2402
  });
2403
2403
 
@@ -6215,8 +6215,10 @@ var init_each = __esm({
6215
6215
  var execute_exports = {};
6216
6216
  __export(execute_exports, {
6217
6217
  DATA_KEY: () => DATA_KEY,
6218
+ HELP_FLAG_MAX: () => HELP_FLAG_MAX,
6218
6219
  HELP_NOTES_MAX: () => HELP_NOTES_MAX,
6219
6220
  buildRequest: () => buildRequest,
6221
+ helpFlagText: () => helpFlagText,
6220
6222
  helpNotes: () => helpNotes,
6221
6223
  operationExamples: () => operationExamples,
6222
6224
  operationFlags: () => operationFlags,
@@ -6522,28 +6524,57 @@ async function runOperation(ctx, op, flags) {
6522
6524
  ctx.output.object(unwrap(res.body));
6523
6525
  return ExitCode.Ok;
6524
6526
  }
6527
+ function normalizedNotes(op) {
6528
+ return (op.description ?? "").trim().replace(/\n[ \t]+/g, "\n");
6529
+ }
6525
6530
  function helpNotes(op) {
6526
- const text2 = (op.description ?? "").trim().replace(/\n[ \t]+/g, "\n");
6531
+ const text2 = normalizedNotes(op);
6527
6532
  if (text2.length <= HELP_NOTES_MAX) return text2;
6528
6533
  const head = text2.slice(0, HELP_NOTES_MAX);
6529
6534
  const end = Math.max(head.lastIndexOf(". "), head.lastIndexOf(".\n"));
6530
- const cut = end > HELP_NOTES_MAX / 3 ? head.slice(0, end + 1) : `${head.trimEnd()}...`;
6531
- return `${cut}
6532
- More notes: kenso describe ${op.resource} ${op.action}.`;
6535
+ return end > HELP_NOTES_MAX / 3 ? head.slice(0, end + 1) : `${head.trimEnd()}...`;
6536
+ }
6537
+ function helpFlagText(f) {
6538
+ const enumText = f.enum ? ` One of: ${f.enum.join(", ")}.` : "";
6539
+ let base = f.description;
6540
+ if (enumText && base.endsWith(enumText)) base = base.slice(0, -enumText.length);
6541
+ const placeholder = f.param ? /^\S+ \((?:path|query|body), ([\w-]+)\)$/.exec(base) : null;
6542
+ if (placeholder) return { text: `${placeholder[1]}${enumText}`, cut: false };
6543
+ let text2 = base;
6544
+ const sentence = /\.(?=\s+[A-Z])/.exec(text2);
6545
+ if (sentence) text2 = text2.slice(0, sentence.index + 1);
6546
+ if (text2.length > HELP_FLAG_MAX) {
6547
+ const head = text2.slice(0, HELP_FLAG_MAX);
6548
+ text2 = `${head.slice(0, Math.max(head.lastIndexOf(" "), HELP_FLAG_MAX / 2)).replace(/[,;:]$/, "")}...`;
6549
+ }
6550
+ return { text: `${text2}${enumText}`, cut: text2 !== base };
6533
6551
  }
6534
6552
  function operationToCommand(op) {
6535
6553
  const flags = {};
6554
+ let cut = false;
6536
6555
  for (const f of operationFlags(op)) {
6556
+ const shown = helpFlagText(f);
6557
+ cut ||= shown.cut;
6537
6558
  const spec = {
6538
6559
  type: f.type === "boolean" ? "boolean" : "string",
6539
6560
  // The dispatcher would enforce `required` before --each rows can fill it, so the command checks it instead.
6540
- description: f.required ? `${f.description} (required)` : f.description
6561
+ description: f.required ? `${shown.text} (required)` : shown.text
6541
6562
  };
6542
6563
  if (f.multiple) spec.multiple = true;
6564
+ if (FOOTER_FLAGS.has(f.flag) && !f.param) spec.helpHidden = true;
6543
6565
  flags[f.flag] = spec;
6544
6566
  }
6545
- const write2 = op.method === "GET" ? "" : "\n\nA write: it sends at once. Run it with --dry-run first, then without. Many rows: --each <file> --dry-run, then --yes.";
6567
+ const write2 = op.method === "GET" ? "" : "\nA write: it sends at once. Run it with --dry-run first, then without.";
6546
6568
  const api = `API: ${op.method} ${op.path} (${op.id}).${write2}`;
6569
+ const named = [...op.routeParams, ...op.queryParams].find((p) => p.flag !== p.name);
6570
+ const keys = named ? `keys fill flags, by flag or API name: ${named.flag} or ${named.name}` : "keys fill flags";
6571
+ const rows = `Many rows: --each <file>, once per CSV or JSONL row (${keys}${op.body ? "; other keys the body" : ""}; --concurrency 1-16)${op.method === "GET" ? "" : ", --dry-run then --yes"}.`;
6572
+ const more = [
6573
+ op.body ? "--data <json|@file|->: the body (flags win)." : "",
6574
+ // The same text helpNotes measures, so the pointer shows only when something was left out.
6575
+ cut || normalizedNotes(op).length > HELP_NOTES_MAX ? `Full notes: kenso describe ${op.resource} ${op.action}.` : ""
6576
+ ].filter(Boolean);
6577
+ const footer = [rows, ...more.length ? [more.join(" ")] : []];
6547
6578
  return {
6548
6579
  resource: op.resource,
6549
6580
  action: op.action,
@@ -6552,13 +6583,19 @@ function operationToCommand(op) {
6552
6583
 
6553
6584
  ${api}` : api,
6554
6585
  flags,
6555
- examples: operationExamples(op),
6586
+ // describe keeps the --data example; help shows the dry run and the apply, and for a write the file of rows
6587
+ // (agents copy examples, and one with only --<id> flags led them to one call per row).
6588
+ examples: [
6589
+ ...operationExamples(op).filter((e) => !e.endsWith(" --data @body.json")),
6590
+ ...op.method === "GET" ? [] : [`${commandOf(op)} --each rows.csv --dry-run # a file of rows: one command`]
6591
+ ],
6592
+ helpFooter: footer,
6556
6593
  ...op.featureGates ? { beta: { featureGates: op.featureGates } } : {},
6557
6594
  operationIds: [op.id],
6558
6595
  run: (ctx, args) => runOperation(ctx, op, args.flags)
6559
6596
  };
6560
6597
  }
6561
- var DATA_KEY, SCALAR_TYPES, LIST_PARAM_TEXT, HELP_NOTES_MAX;
6598
+ var DATA_KEY, SCALAR_TYPES, LIST_PARAM_TEXT, HELP_NOTES_MAX, HELP_FLAG_MAX, FOOTER_FLAGS;
6562
6599
  var init_execute = __esm({
6563
6600
  "src/generated/execute.ts"() {
6564
6601
  "use strict";
@@ -6572,7 +6609,9 @@ var init_execute = __esm({
6572
6609
  limit: "Page size.",
6573
6610
  cursor: "Start at this cursor (the next: hint of a previous page)."
6574
6611
  };
6575
- HELP_NOTES_MAX = 600;
6612
+ HELP_NOTES_MAX = 320;
6613
+ HELP_FLAG_MAX = 140;
6614
+ FOOTER_FLAGS = /* @__PURE__ */ new Set(["each", "concurrency", "data"]);
6576
6615
  }
6577
6616
  });
6578
6617
 
@@ -6909,13 +6948,14 @@ var init_import = __esm({
6909
6948
  resource: "accounts",
6910
6949
  action: "import",
6911
6950
  summary: "Create accounts (companies) from a CSV or JSONL file, charged per created account; a file of people: people import",
6951
+ brief: "companies, charged per new account",
6912
6952
  description: "Sends the rows to accounts.bulk-create, 100 per call. Columns map by name (case, spaces and dashes ignored) to the account fields: name (or company), domains (split on , or ;) or one domain (domain, website, companyDomain), industry, employeeCount, hqLocation, linkedinUrl, stage, angle, whyNow, fitScore, fundingRound, cadenceDays, ownerMemberId, kenCompanyId, sendsFromInboxId, sendsFromLinkedInAccountId and source. A company that is already an account is skipped (domain_taken) with that account's id. Without --yes it prints the plan with a credit estimate; research runs for each created account.",
6913
6953
  flags: {
6914
6954
  file: { type: "string", required: true, description: "The CSV or JSONL file, or - for stdin" },
6915
6955
  concurrency: { type: "string", description: "Calls in flight, 1 to 16 (default 4)" },
6916
6956
  out: { type: "string", description: "Keep the run's files in this directory instead of ~/.kenso/runs" }
6917
6957
  },
6918
- examples: ["kenso accounts import --file accounts.csv", "kenso accounts import --file accounts.csv --yes"],
6958
+ examples: ["kenso accounts import --file accounts.csv --dry-run", "kenso accounts import --file accounts.csv --yes"],
6919
6959
  beta: { featureGates: { all: ["accounts"] } },
6920
6960
  operationIds: ["accounts.bulk-create", "credit-estimates.create"],
6921
6961
  async run(ctx, args) {
@@ -7008,7 +7048,8 @@ var init_api3 = __esm({
7008
7048
  command2 = {
7009
7049
  resource: "api",
7010
7050
  action: "",
7011
- summary: "Call any Kenso API route with auth and headers set",
7051
+ summary: "Call any API route, only when no command fits",
7052
+ brief: "only when no command fits",
7012
7053
  description: "The raw escape hatch: sends Authorization, Ken-Version: 1.1 and X-Ken-Workspace-Id, prints the full JSON body. The path may carry a query string. --all (GET only) follows pagination.nextCursor and prints the data[] items as a list. Writes run without --yes.",
7013
7054
  flags: {
7014
7055
  data: { type: "string", description: "Request body: inline JSON, @file or - for stdin" },
@@ -7319,7 +7360,8 @@ var init_login = __esm({
7319
7360
  command3 = {
7320
7361
  resource: "auth",
7321
7362
  action: "login",
7322
- summary: "Save an API key on this machine (--with-key reads it from stdin)",
7363
+ summary: "Save an API key (--with-key reads it from stdin: pipe a variable in, no check first)",
7364
+ brief: "--with-key reads the key from stdin: pipe a variable in, no check first",
7323
7365
  description: "With --with-key, reads one API key from stdin, checks it with GET /v1/me and saves it to the profile (--profile, KENSO_PROFILE or the active one) in ~/.kenso/config.json with mode 0600. The workspace is --workspace when the key can reach it, else the key's only workspace. The key is never a flag value. A key in any environment variable: pipe that variable in as the example shows, with no check first (an empty or malformed key is refused, exit 2, without printing it).",
7324
7366
  flags: {
7325
7367
  "with-key": { type: "boolean", description: "Read the API key from stdin" },
@@ -7434,7 +7476,7 @@ var init_logout = __esm({
7434
7476
  command4 = {
7435
7477
  resource: "auth",
7436
7478
  action: "logout",
7437
- summary: "Remove a saved profile and its key from this machine",
7479
+ summary: "Remove a saved profile and its key",
7438
7480
  description: "Removes the profile (--profile, KENSO_PROFILE or the active one) from ~/.kenso/config.json. The key itself stays valid: revoke it at Settings > Integrations > API keys.",
7439
7481
  flags: {},
7440
7482
  examples: ["kenso auth logout", "kenso auth logout --profile client-a"],
@@ -7470,7 +7512,7 @@ var init_status = __esm({
7470
7512
  command5 = {
7471
7513
  resource: "auth",
7472
7514
  action: "status",
7473
- summary: "Show who the key belongs to, the active workspace and when the key expires",
7515
+ summary: "Show the key's user, workspace and expiry",
7474
7516
  description: `Reads GET /v1/me for the email and workspaces, and GET /v1/api-keys for the key's expiry. Warns on stderr when the key expires within ${EXPIRY_WARNING_DAYS} days.`,
7475
7517
  flags: {},
7476
7518
  examples: ["kenso auth status", "kenso auth status --profile client-a"],
@@ -8200,7 +8242,7 @@ var init_wait = __esm({
8200
8242
  command6 = {
8201
8243
  resource: "jobs",
8202
8244
  action: "wait",
8203
- summary: "Wait for a background job to finish and print it",
8245
+ summary: "Wait for a background job and print it",
8204
8246
  description: "Polls GET /v1/jobs/{jobId}?type=<type> every 2 s (every 10 s after the first minute) until the job succeeds, fails or is canceled, then prints the job. Exit 0 when it succeeded, 1 when it failed, was canceled or is still running when --timeout passes.",
8205
8247
  flags: {
8206
8248
  type: { type: "string", required: true, description: "The job type, for example contact_import or contact_export" },
@@ -8440,7 +8482,8 @@ var init_import2 = __esm({
8440
8482
  command7 = {
8441
8483
  resource: "contacts",
8442
8484
  action: "import",
8443
- summary: "Add leads from a CSV or JSONL file to a campaign, with one result per row",
8485
+ summary: "Add leads from a CSV or JSONL file to a campaign",
8486
+ brief: "a file into a campaign",
8444
8487
  description: "Maps columns by header (email, firstName, lastName, title, linkedInUrl, companyName, companyDomain; every other column becomes a custom variable), checks every row locally, then sends 1,000 rows per call to POST /v1/campaigns/{campaignId}/contacts. Without --yes it prints the plan and the credit estimate. Rerunning the same command on the same file resumes the run. Exit 5 when some rows failed or were invalid, 6 when the contact limit stopped the run.",
8445
8488
  flags: {
8446
8489
  campaign: { type: "string", description: "The campaign id (required unless --template)" },
@@ -8452,10 +8495,8 @@ var init_import2 = __esm({
8452
8495
  out: OUT_FLAG
8453
8496
  },
8454
8497
  examples: [
8455
- "kenso contacts import --template",
8456
- "kenso contacts import --campaign 812 --file leads.csv",
8498
+ "kenso contacts import --campaign 812 --file leads.csv --dry-run",
8457
8499
  "kenso contacts import --campaign 812 --file leads.csv --yes",
8458
- `kenso contacts import --campaign 812 --file leads.csv --map '{"Pain": "customVariables.pain_point", "Notes": null}' --yes`,
8459
8500
  "kenso contacts import --campaign 812 --file big.csv --server-import --yes"
8460
8501
  ],
8461
8502
  operationIds: ["contacts.create", "campaigns.get", "credit-estimates.create", "contact-imports.create", "jobs.get"],
@@ -8647,7 +8688,8 @@ var init_update = __esm({
8647
8688
  command8 = {
8648
8689
  resource: "contacts",
8649
8690
  action: "update",
8650
- summary: "Edit leads from a CSV or JSONL file, matched by id or by email (also one lead)",
8691
+ summary: "Edit leads from a CSV or JSONL file, by id or email (also one lead)",
8692
+ brief: "a file, by id or email; also one lead",
8651
8693
  description: "Matches each row to a contact by its id (--match id, an id or contactId column) or by email within a campaign (--match email --campaign <id>), then sends 1,000 items per call to POST /v1/contacts/bulk-update. A column that is absent or a blank cell leaves the field unchanged; the literal null clears it. Other columns are custom variables. Without --yes it prints the plan. Exit 5 when some rows failed or were not found.",
8652
8694
  flags: {
8653
8695
  file: { type: "string", description: "CSV (with a header row) or JSONL file, or - for stdin" },
@@ -8658,9 +8700,10 @@ var init_update = __esm({
8658
8700
  concurrency: CONCURRENCY_FLAG
8659
8701
  },
8660
8702
  examples: [
8661
- "kenso contacts update --file edits.csv --match id",
8703
+ "kenso contacts update --file edits.csv --match id --dry-run",
8662
8704
  "kenso contacts update --file edits.csv --match id --yes",
8663
- "kenso contacts update --file edits.csv --match email --campaign 812 --yes"
8705
+ "kenso contacts update --file edits.csv --match email --campaign 812 --dry-run",
8706
+ "printf 'id,title\\n9001,VP Sales\\n' | kenso contacts update --file - --match id --dry-run # one lead"
8664
8707
  ],
8665
8708
  operationIds: ["contacts.bulk-update", "contacts.list"],
8666
8709
  async run(ctx, { flags }) {
@@ -8920,20 +8963,21 @@ function bulkCommand(kind) {
8920
8963
  ids: { type: "string", description: "Contact ids: one per line, or a CSV/JSONL with an id column; - for stdin" }
8921
8964
  };
8922
8965
  if (kind === "restore") flags.run = { type: "string", description: "Restore what an earlier contacts remove run removed" };
8923
- else flags.where = { type: "string", description: "contacts search filters as JSON, @file or - (the body of POST /v1/contacts/search). The dry-run plan shows what matches: no separate search first" };
8966
+ else flags.where = { type: "string", description: "contacts search filters as JSON, @file or - (the body of POST /v1/contacts/search: campaignIds, q matches names and emails, filters match fields such as ContactCompany.Name). Its dry-run plan shows the count and first ids: check the match there, never with a search or list first" };
8924
8967
  if (kind === "remove") flags.confirm = { type: "string", description: "Required to remove more than 1,000 contacts: the exact count" };
8925
8968
  flags.out = OUT_FLAG;
8926
8969
  flags.concurrency = CONCURRENCY_FLAG;
8927
8970
  return {
8928
8971
  resource: "contacts",
8929
8972
  action: kind,
8930
- summary: kind === "restore" ? `${route.verb} contacts by id (--ids) or by a remove run (--run)` : `${route.verb} contacts by id (--ids file) or by search (--where), 1,000 per call`,
8973
+ summary: kind === "restore" ? `${route.verb} contacts by --ids or by a remove run (--run)` : `${route.verb} contacts by --ids file or --where filters`,
8974
+ brief: kind === "restore" ? "--ids or --run" : "--ids file or --where, no search first",
8931
8975
  description: `Sends the ids to POST ${route.path} in chunks of 1,000 and reports each id: ok, skipped (nothing to do), not_found or failed. Ids come from --ids` + (kind === "restore" ? " or from an earlier remove run (--run <runId>)" : " or from a contacts search (--where); a rerun of an unfinished --where run keeps its ids") + ". Without --yes it prints the count and the first ids." + (kind === "remove" ? " More than 1,000 ids also needs --confirm <count>. The summary prints the undo command." : ""),
8932
8976
  flags,
8933
8977
  examples: [
8934
8978
  `kenso contacts ${kind} --ids ids.txt`,
8935
8979
  `kenso contacts ${kind} --ids ids.txt --yes`,
8936
- ...kind === "restore" ? ["kenso contacts restore --run r_ab3dxk2q --yes"] : [`kenso contacts ${kind} --where '{"campaignIds": [812], "q": "acme"}' --dry-run`],
8980
+ ...kind === "restore" ? ["kenso contacts restore --run r_ab3dxk2q --yes"] : [`kenso contacts ${kind} --where '{"campaignIds": [812], "filters": [{"conditions": {"field": "ContactCompany.Name", "operator": 2, "value": "acme"}}]}' --dry-run`],
8937
8981
  ...kind === "remove" ? ["kenso contacts remove --ids big.csv --confirm 2500 --yes"] : []
8938
8982
  ],
8939
8983
  ...kind === "pause" ? { beta: { featureGates: { all: ["pause_leads"] } } } : {},
@@ -9074,7 +9118,8 @@ function tagCommand(kind) {
9074
9118
  return {
9075
9119
  resource: "contacts",
9076
9120
  action: kind,
9077
- summary: `${kind === "tag" ? "Add a tag to" : "Remove a tag from"} contacts by id (--ids only: for a campaign, contacts list its ids first)`,
9121
+ summary: `${kind === "tag" ? "Add a tag to" : "Remove a tag from"} contacts by --ids only (a campaign: list its ids first)`,
9122
+ brief: "--ids only",
9078
9123
  description: (kind === "tag" ? "Calls POST /v1/tags/{tagId}/assignments once per contact id (a contact that already has the tag is skipped). " : "Calls DELETE /v1/tags/{tagId}/assignments once per contact id. ") + "Ids come one per line, or from a CSV/JSONL with an id column. Without --yes it prints the count. Exit 5 when some ids failed or were not found.",
9079
9124
  flags: {
9080
9125
  tag: { type: "string", description: "The tag id (see kenso tags list)" },
@@ -9082,7 +9127,7 @@ function tagCommand(kind) {
9082
9127
  out: OUT_FLAG,
9083
9128
  concurrency: CONCURRENCY_FLAG
9084
9129
  },
9085
- examples: [`kenso contacts ${kind} --tag 31 --ids ids.txt`, `kenso contacts ${kind} --tag 31 --ids ids.txt --yes`],
9130
+ examples: [`kenso contacts ${kind} --tag 31 --ids ids.txt --dry-run`, `kenso contacts ${kind} --tag 31 --ids ids.txt --yes`],
9086
9131
  operationIds: [kind === "tag" ? "tags.assign" : "tags.unassign"],
9087
9132
  async run(ctx, { flags }) {
9088
9133
  const tagId = idFlag(flags, "tag", name);
@@ -9197,7 +9242,8 @@ var init_export = __esm({
9197
9242
  command9 = {
9198
9243
  resource: "contacts",
9199
9244
  action: "export",
9200
- summary: "Export a campaign's contacts to a CSV file (a segment: kenso contact-exports create)",
9245
+ summary: "Export a campaign's contacts to CSV (a segment: contact-exports create)",
9246
+ brief: "a campaign to CSV; a segment: contact-exports create",
9201
9247
  description: "Creates a contact export of the campaign (POST /v1/contact-exports, kind contacts), waits for the contact_export job, then downloads GET /v1/contact-exports/{exportId}/content to --out. Without --out it prints the job (add --wait to wait for it).",
9202
9248
  flags: {
9203
9249
  campaign: { type: "string", description: "The campaign id" },
@@ -9389,7 +9435,8 @@ var init_describe = __esm({
9389
9435
  command10 = {
9390
9436
  resource: "describe",
9391
9437
  action: "",
9392
- summary: "Print the command map as JSON: every command, its flags and body schema",
9438
+ summary: "The command map as JSON: commands, flags, body schemas",
9439
+ brief: "",
9393
9440
  description: "Always JSON. No arguments: every command with its summary. A resource: its commands with their flags. A resource and an action: one command in full (method, path, flags, body schema, examples). Beta commands your workspace has not enabled are left out unless --all-commands; calling one still works.",
9394
9441
  flags: {
9395
9442
  // Used by scripts/build-reference.ts to write docs/reference.md in one call.
@@ -9469,7 +9516,7 @@ var init_batch = __esm({
9469
9516
  var crm_default;
9470
9517
  var init_crm = __esm({
9471
9518
  "docs/crm.md"() {
9472
- crm_default = "# CRM (beta)\n\nThe CRM commands work on Kenso's Accounts lane: accounts (companies), the people on them, and opportunities. They are beta: they need the `accounts` feature flag on your workspace (opportunities need `opportunities`, CRM sync needs `ingestion`). Help and `kenso describe` hide them until your workspace has the flag; `--all-commands` shows them.\n\n| Command | What it does |\n|---|---|\n| `accounts import` | Create accounts from a CSV or JSONL file. Research is charged per created account |\n| `people import` | Create or match people from a file, optionally onto accounts |\n| `accounts update --each`, `people update --each`, `opportunities update --each` | Edit many records from a file, one call per row |\n| `crm-connections`, `crm-imports`, `crm-sync-issues` | Read and run CRM sync. Connecting a CRM stays in the app |\n\nImport accounts first, then people: `people import` never creates an account.\n\n## Import accounts\n\n```bash\nkenso accounts import --file accounts.csv\nkenso accounts import --file accounts.csv --yes\n```\n\n```csv\nname,domain,industry,employeeCount\nAcme,acme.io,Software,120\nGlobex,globex.com,Manufacturing,\"1,200\"\n```\n\n- **Columns** map by name, ignoring case, spaces and dashes: `name` (or `company`, `companyName`, `accountName`), `domains` (several, split on `,` or `;`) or one `domain` (or `website`, `companyDomain`), `industry`, `employeeCount`, `hqLocation`, `linkedinUrl`, `stage`, `angle`, `whyNow`, `fitScore`, `fundingRound`, `cadenceDays`, `ownerMemberId`, `kenCompanyId`, `sendsFromInboxId`, `sendsFromLinkedInAccountId` and `source`.\n- **A domain is required.** A file with no domain column is a usage error, and a row without a domain is `invalid` (`domain_required`).\n- **The dry run prices the research.** It asks for a credit estimate per 1,000 accounts and puts the sum in the plan: `kind`, `accountCount`, `totalMillicredits`, `availableMillicredits` and `sufficient`.\n- **Rows go 100 per call** to `POST /v1/accounts/bulk-create`.\n\nEach row comes back:\n\n| Status | Meaning |\n|---|---|\n| `created` | A new account; `id` is the account id. Research is queued (`researchQueued` in the summary), or waits for credits (`researchWaitingCredits`) |\n| `skipped`, reason `domain_taken` | A company that is already an account; `id` is that account |\n| `failed` | Kenso refused the row (`reason` is the API's code, `error` names the field) or failed to save it. An item Kenso failed to save is sent once more before it counts as failed |\n\nRerunning the same file is safe: accounts that exist come back `domain_taken`.\n\n## Import people\n\n```bash\nkenso people import --file people.csv\nkenso people import --file people.csv --find-emails --yes\nkenso people import --file people.jsonl --dry-run\n```\n\n```csv\nfullName,email,title,accountDomain,role\nAnn Lee,ann@acme.io,VP Sales,acme.io,champion\nBob Ray,,CTO,acme.io,\n```\n\n- **Columns** map by name, ignoring case, spaces and dashes: `email`, `fullName` (or `name`), `firstName`, `lastName`, `title`, `linkedinUrl` (or `linkedin`, `profileUrl`), `accountDomain` (or `domain`, `website`, `companyDomain`), `accountId`, `role` and `priority`. Other columns are ignored with one warning.\n- **A person needs a name:** `fullName`, or `firstName`, `lastName` or both (the CLI joins them). A row with none of them is `invalid` (`name_required`).\n- **To put a person on an account,** give `accountDomain` or `accountId`. A row with both sends only `accountId`.\n- **`--find-emails`** queues an email find (charged in credits) for each person without an email.\n- **Rows go 200 per call** to `POST /v1/people/batch`.\n\nPeople already in the workspace are matched by email, then LinkedIn URL, so running the same file again creates nobody twice.\n\n| Result | Row status | reason |\n|---|---|---|\n| A new person | `created` | |\n| An existing person, added to the account | `updated` | |\n| An existing person already on the account | `skipped` | `already_on_account` |\n| An existing person, no account asked | `skipped` | `matched` |\n| The person was removed from the account before | `skipped` | `previously_removed` |\n| The account does not exist | `failed` | `account_not_found`: the person was saved without an account. Import the account, then rerun to place them |\n| Kenso refused the row | `failed` | the API's reason |\n\nThe summary adds `matched`, `addedToAccounts` and `emailFindsQueued`.\n\n## Edit accounts, people and opportunities\n\nApart from `accounts bulk-update`, which sets one `accountType` on up to 500 accounts, there is no bulk edit route, so run the generated `update` command once per row with `--each`. Columns are the command's flags by API name or flag name, and the id column fills the route:\n\n```bash\nkenso accounts update --each accounts-edit.csv --dry-run\nkenso accounts update --each accounts-edit.csv --yes\nkenso people update --each people-edit.csv --yes\nkenso opportunities update --each deals.csv --yes\n```\n\n```csv\naccountId,stage,ownerMemberId,overwritePinned\n501,engaged,42,true\n502,customer,42,true\n```\n\nA row that changes a field someone pinned in the app fails as a whole (`409 conflict`, `pinned_by_user`: nothing on that record changes, the other fields of the row included), unless the row sets `overwritePinned` to `true` (or you pass `--overwrite-pinned`). To see every flag of an update command:\n\n```bash\nkenso describe accounts update\nkenso accounts update --help\n```\n\n## CRM sync\n\nCRM sync has generated commands only: read connections, preview, start an import, and work through sync issues. Connect the CRM itself in the app.\n\n```bash\nkenso crm-connections list\nkenso crm-connections get --connection-id 7\nkenso crm-imports create --connection-id 7 --dry-run\nkenso crm-sync-issues list --connection-id 7 --all\n```\n\nRun `kenso crm-connections --help` (or add `--all-commands` when the actions are hidden) to see every action.\n";
9519
+ crm_default = "# CRM (beta)\n\nThe CRM commands work on Kenso's Accounts lane: accounts (companies), the people on them, and opportunities. They are beta: they need the `accounts` feature flag on your workspace (opportunities need `opportunities`, CRM sync needs `ingestion`). Help and `kenso describe` hide them until your workspace has the flag; `--all-commands` shows them.\n\n| Command | What it does |\n|---|---|\n| `accounts import` | Create accounts from a CSV or JSONL file. Research is charged per created account |\n| `people import` | Create or match people from a file, optionally onto accounts |\n| `accounts update --each`, `people update --each`, `opportunities update --each` | Edit many records from a file, one call per row |\n| `crm-connections`, `crm-imports`, `crm-sync-issues` | Read and run CRM sync. Connecting a CRM stays in the app |\n\nImport accounts first, then people: `people import` never creates an account.\n\n## Import accounts\n\n```bash\nkenso accounts import --file accounts.csv\nkenso accounts import --file accounts.csv --yes\n```\n\n```csv\nname,domain,industry,employeeCount\nAcme,acme.io,Software,120\nGlobex,globex.com,Manufacturing,\"1,200\"\n```\n\n- **Columns** map by name, ignoring case, spaces and dashes: `name` (or `company`, `companyName`, `accountName`), `domains` (several, split on `,` or `;`) or one `domain` (or `website`, `companyDomain`), `industry`, `employeeCount`, `hqLocation`, `linkedinUrl`, `stage`, `angle`, `whyNow`, `fitScore`, `fundingRound`, `cadenceDays`, `ownerMemberId`, `kenCompanyId`, `sendsFromInboxId`, `sendsFromLinkedInAccountId` and `source`.\n- **A domain is required.** A file with no domain column is a usage error, and a row without a domain is `invalid` (`domain_required`).\n- **The dry run prices the research.** It asks for a credit estimate per 1,000 accounts and puts the sum in the plan: `kind`, `accountCount`, `totalMillicredits`, `availableMillicredits` and `sufficient`.\n- **Rows go 100 per call** to `POST /v1/accounts/bulk-create`.\n\nEach row comes back `created` (`id` is the new account; its research is queued, `researchQueued`, or waits for credits, `researchWaitingCredits`), `skipped` with reason `domain_taken` (already an account; `id` is that account) or `failed` (`reason` is the API's code, `error` names the field; an item Kenso failed to save is sent once more first). Rerunning the same file is safe: accounts that exist come back `domain_taken`.\n\n## Import people\n\n```bash\nkenso people import --file people.csv\nkenso people import --file people.csv --find-emails --yes\nkenso people import --file people.jsonl --dry-run\n```\n\n```csv\nfullName,email,title,accountDomain,role\nAnn Lee,ann@acme.io,VP Sales,acme.io,champion\nBob Ray,,CTO,acme.io,\n```\n\n- **Columns** map by name, ignoring case, spaces and dashes: `email`, `fullName` (or `name`), `firstName`, `lastName`, `title`, `linkedinUrl` (or `linkedin`, `profileUrl`), `accountDomain` (or `domain`, `website`, `companyDomain`), `accountId`, `role` and `priority`. Other columns are ignored with one warning.\n- **A person needs a name:** `fullName`, or `firstName`, `lastName` or both (the CLI joins them). A row with none of them is `invalid` (`name_required`).\n- **To put a person on an account,** give `accountDomain` or `accountId`. A row with both sends only `accountId`.\n- **`--find-emails`** queues an email find (charged in credits) for each person without an email.\n- **Rows go 200 per call** to `POST /v1/people/batch`.\n\nPeople already in the workspace are matched by email, then LinkedIn URL, so running the same file again creates nobody twice. A row comes back `created` (a new person), `updated` (an existing person added to the account), `skipped` with reason `already_on_account`, `matched` (no account asked) or `previously_removed`, or `failed`: `account_not_found` saves the person without an account (import the account, then rerun to place them), any other reason is the API's. The summary adds `matched`, `addedToAccounts` and `emailFindsQueued`.\n\n## Edit accounts, people and opportunities\n\nApart from `accounts bulk-update`, which sets one `accountType` on up to 500 accounts, there is no bulk edit route, so run the generated `update` command once per row with `--each`. Columns are the command's flags by API name or flag name, and the id column fills the route:\n\n```bash\nkenso accounts update --each accounts-edit.csv --dry-run # then the same with --yes\nkenso people update --each people-edit.csv --dry-run\nkenso opportunities update --each deals.csv --dry-run\n```\n\n```csv\naccountId,stage,ownerMemberId,overwritePinned\n501,engaged,42,true\n502,customer,42,true\n```\n\nA row that changes a field someone pinned in the app fails as a whole (`409 conflict`, `pinned_by_user`: nothing on that record changes, the other fields of the row included), unless the row sets `overwritePinned` to `true` (or you pass `--overwrite-pinned`, only with the person's OK). Every flag of an update command: `kenso accounts update --help`.\n\n## CRM sync\n\nCRM sync has generated commands only: read connections, preview, start an import, and work through sync issues. Connect the CRM itself in the app.\n\n```bash\nkenso crm-connections list\nkenso crm-connections get --connection-id 7\nkenso crm-imports create --connection-id 7 --dry-run\nkenso crm-sync-issues list --connection-id 7 --all\n```\n\nRun `kenso crm-connections --help` (or add `--all-commands` when the actions are hidden) to see every action.\n";
9473
9520
  }
9474
9521
  });
9475
9522
 
@@ -9477,7 +9524,7 @@ var init_crm = __esm({
9477
9524
  var leads_default;
9478
9525
  var init_leads = __esm({
9479
9526
  "docs/leads.md"() {
9480
- leads_default = '# Leads\n\nLeads are contacts in a campaign. The lead commands live under `kenso contacts`, and `kenso leads` is the same thing: `kenso leads import` runs `kenso contacts import`.\n\n| Command | What it does |\n|---|---|\n| `contacts import` | Add leads from a CSV or JSONL file to a campaign, with one result per row |\n| `contacts update` | Edit leads in bulk from a file, matched by id or by email |\n| `contacts remove` / `restore` | Remove (soft-delete) leads by id, and undo it |\n| `contacts pause` / `resume` | Pause and resume the campaign sequence of leads (pause is beta, `pause_leads` flag) |\n| `contacts tag` / `untag` | Add or remove a tag on leads by id |\n| `contacts export` | Download a campaign\'s leads as one CSV file |\n\nAll of them except `export` are batch commands: without `--yes` they print a plan and change nothing. [Batch runs](batch.md) explains plans, run folders, results files and resume.\n\n## Import a CSV into a campaign\n\n```bash\nkenso contacts import --template\nkenso contacts import --campaign 812 --file leads.csv --dry-run\nkenso contacts import --campaign 812 --file leads.csv --yes\n```\n\n`--template` prints the accepted columns. A minimal file:\n\n```csv\nemail,firstName,lastName,companyName,Pain Point\nann@acme.io,Ann,Lee,Acme,Slow onboarding\ncid@globex.com,Cid,Moe,Globex,Churn\n```\n\n### Columns\n\nHeaders are matched without regard to case, spaces or punctuation. The known fields and the other headers each one reads:\n\n| Field | Also read from | Rule |\n|---|---|---|\n| `email` | email address, e-mail, work email | Required, unless the campaign is LinkedIn-only and the row has a linkedin.com/in/ URL |\n| `firstName` | first name, first, given name | Required |\n| `lastName` | last name, last, surname, family name | Required |\n| `title` | job title, position, role | Optional |\n| `linkedInUrl` | linkedin, linkedin url, linkedin profile, profile url | Optional |\n| `companyName` | company, organization, account, account name | `companyName` or `companyDomain` is required |\n| `companyDomain` | website, domain, company website, url | `companyName` or `companyDomain` is required |\n\nEvery other column becomes a custom variable, named like its header in snake_case: a `Pain Point` column fills `{{pain_point}}` in your sequence. Names Kenso keeps for its own fields (`city`, `country`, `phone_number` and others) are not sent: the CLI warns and the plan lists them under `droppedColumns`. Rename them with `--map`.\n\n### Cells\n\n- A blank cell is never sent, so importing a file never erases data on a lead that is already in the campaign.\n- The literal value `null` clears a custom variable.\n\n### Rename or drop columns with --map\n\n`--map` takes a JSON object, `@file` or `-` for stdin. A value names the target field, and `null` drops the column:\n\n```bash\nkenso contacts import --campaign 812 --file leads.csv --map \'{"Pain": "customVariables.pain_point", "Notes": null}\' --yes\n```\n\n### JSONL\n\nEach line is one lead. Keys work like CSV headers, and a `customVariables` object is spread into custom variables:\n\n```json\n{"email":"ann@acme.io","firstName":"Ann","lastName":"Lee","companyName":"Acme","customVariables":{"pain_point":"Slow onboarding"}}\n```\n\n```bash\nkenso contacts import --campaign 812 --file leads.jsonl --yes\ncat leads.jsonl | kenso contacts import --campaign 812 --file - --yes\n```\n\nA run read from stdin cannot be resumed with `kenso runs resume`, so prefer a file for large imports.\n\n### What the CLI checks before sending\n\nEvery row is checked locally first. A row that fails goes to `invalid.csv` with one of these reasons and is not sent:\n\n| Reason | Meaning |\n|---|---|\n| `missing_email` | The row has no email (and is not a LinkedIn-only lead) |\n| `invalid_email` | The email is not a valid address |\n| `missing_first_name` | The row has no first name |\n| `missing_last_name` | The row has no last name |\n| `missing_company` | The row has neither a company name nor a company domain |\n| `sales_navigator_url` | An email-less row whose LinkedIn URL is a Sales Navigator link: add the email or a linkedin.com/in/ URL |\n| `duplicate_in_file` | An earlier row of the file has the same email |\n\nKenso stays the authority and checks every row again.\n\n### What the dry run shows\n\nThe dry run maps the columns, checks the rows, reads the campaign, and asks for a credit estimate (`credit-estimates create`, kind `leads`). Its plan holds `rows`, `valid`, `invalid`, `mapping`, `customVariableKeys`, `estimate`, `credits` (`needed`, `available`, `sufficient`) and `next`, the exact apply command. If the estimate call fails, the plan still prints with `"estimate":null` and an `estimateError`.\n\n### How rows are sent\n\nThe CLI sends 1,000 rows per call to `POST /v1/campaigns/{campaignId}/contacts`, one call at a time per campaign, because two overlapping upserts of the same email could create duplicates. After a 5xx answer or a dropped connection it does not retry right away: it waits 130 seconds, so the first request has finished on the server, then resends the chunk once. Rows the first request saved come back `updated`.\n\n### Reading the result\n\nEach row in `results.csv` gets one status:\n\n| Status | Meaning |\n|---|---|\n| `created` | A new lead in the campaign |\n| `updated` | The lead was already there and was updated |\n| `skipped` | Kenso kept the lead out on purpose. `reason` is `duplicate`, `blocklisted`, `already_contacted`, `excluded` or another code |\n| `failed` | Kenso refused the row (`reason` such as `invalid_email` or `validation`, and an `error` message), or the call failed |\n| `invalid` | The CLI refused the row before sending it (see the table above) |\n\nA saved lead that cannot be emailed yet has `sendable` false, and the summary counts those leads in `notSendable` with a warning on stderr.\n\nExit codes: 0 when no row failed (skipped rows are not failures), 5 when some rows failed or were invalid, 6 when the plan\'s or campaign\'s lead limit stopped the run (`limit_reached`; raise the limit, then rerun the same command to send the rest).\n\n## Import a very large file on the server\n\nFor up to 100,000 rows, `--server-import` uploads the CSV as one server import job and waits for it (default 30 minutes, `--timeout`). It reports the job\'s counts but no per-row results.\n\n```bash\nkenso contacts import --campaign 812 --file big.csv --server-import --yes\nkenso contacts import --campaign 812 --file big.csv --server-import --timeout 1h --yes\n```\n\n## Update leads in bulk\n\n```bash\nkenso contacts update --file edits.csv --match id\nkenso contacts update --file edits.csv --match id --yes\nkenso contacts update --file edits.csv --match email --campaign 812 --yes\n```\n\n- `--match id` reads the contact id from an `id` or `contactId` column.\n- `--match email` finds each email among the leads of `--campaign`. An email it cannot find is `not_found`. The email column only finds the lead; it is never sent as an edit.\n- A column that is absent, or a blank cell, leaves that field unchanged. The literal `null` clears it.\n- The lead fields you can edit are `firstName`, `lastName`, `title`, `linkedInUrl`, and `email` with `--match id`. `companyName` and `companyDomain` cannot change here and are not sent. Every other column is a custom variable, mapped the same way as in an import, and `--map` works here too.\n- A row with nothing to change is `skipped` with reason `no_changes` and is not sent. Rows go 1,000 per call to `POST /v1/contacts/bulk-update`.\n\nTo edit one lead, use a one-row file from stdin:\n\n```bash\nprintf \'id,title\\n9001,VP Sales\\n\' | kenso contacts update --file - --match id --yes\n```\n\n## Remove, restore, pause and resume\n\nThese four take contact ids from `--ids` (one id per line, or a CSV or JSONL file with an `id` or `contactId` column, or `-` for stdin), or from a search with `--where` (remove, pause and resume). `--where` takes the body of `POST /v1/contacts/search` as JSON, `@file` or `-`, and does the search itself: its plan shows how many contacts match and the first ids, so run no separate search or list first.\n\n```bash\nkenso contacts remove --ids ids.txt\nkenso contacts remove --ids ids.txt --yes\nkenso contacts remove --where \'{"campaignIds": [812], "q": "acme"}\'\nkenso contacts remove --where \'{"campaignIds": [812], "q": "acme"}\' --yes\nkenso contacts remove --ids big.csv --confirm 2500 --yes\n```\n\n- Without `--yes`, the plan prints the count and the first ids.\n- Removing more than 1,000 contacts also needs `--confirm <count>`, and the count must match. The plan\'s `next` already carries the right `--confirm`.\n- Each id comes back `ok`, `skipped` when there was nothing to do (for example `not_deleted` when you restore a contact that is not removed), `not_found` or `failed`.\n- The remove summary carries the undo command, `"undo":"kenso contacts restore --run r_ubqu7wtx"`.\n\n```bash\nkenso contacts restore --run r_ubqu7wtx --yes\nkenso contacts restore --ids ids.txt --yes\nkenso contacts pause --where \'{"campaignIds": [812]}\' --yes\nkenso contacts resume --ids ids.txt --yes\n```\n\n`restore` takes `--ids` or `--run <runId>` of an earlier remove, not `--where`: a search cannot find removed contacts. `pause` is a beta command for workspaces with the `pause_leads` flag.\n\n## Tag and untag\n\n```bash\nkenso tags list\nkenso contacts tag --tag 31 --ids ids.txt --yes\nkenso contacts untag --tag 31 --ids ids.txt --yes\n```\n\n`tag` calls `POST /v1/tags/{tagId}/assignments` once per contact. A contact that already has the tag is `skipped` with reason `already_tagged`.\n\n## Export a campaign\'s leads\n\n```bash\nkenso contacts export --campaign 812 --out contacts.csv\nkenso contacts export --campaign 812 --wait\n```\n\nWith `--out`, the CLI creates the export, waits for the job (default 30 minutes, `--timeout`) and writes one CSV file (mode 0600). Without `--out` it prints the export job; `--wait` waits for it, or run the `next` it prints, `kenso jobs wait <jobId> --type contact_export`.\n\n## Recipes\n\n**Tag every lead of a campaign.** List the ids, then tag them:\n\n```bash\nkenso contacts list --campaign-id 812 --all -o ids > ids.txt\nkenso contacts tag --tag 31 --ids ids.txt --yes\n```\n\n**Fix the invalid rows and send them.** `invalid.csv` keeps each refused row\'s number and reason. Fix those rows in a new file and import it. Rerunning the original file also works: rows Kenso already saved come back `updated` or `skipped`.\n\n**Retry only what failed.** Filter `results.csv` to the `failed` rows, take those rows from the input by `row` number, and import that file.\n\n**Pause leads who replied elsewhere.** Plan with the filter (the plan shows the count), then apply:\n\n```bash\nkenso contacts pause --where @replied.json\nkenso contacts pause --where @replied.json --yes\n```\n\n**Edit one field on many leads by email.**\n\n```bash\nkenso contacts update --file titles.csv --match email --campaign 812 --dry-run\nkenso contacts update --file titles.csv --match email --campaign 812 --yes\n```\n';
9527
+ leads_default = '# Leads\n\nLeads are contacts in a campaign. The lead commands live under `kenso contacts`, and `kenso leads` is the same thing: `kenso leads import` runs `kenso contacts import`.\n\n| Command | What it does |\n|---|---|\n| `contacts import` | Add leads from a CSV or JSONL file to a campaign, with one result per row |\n| `contacts update` | Edit leads in bulk from a file, matched by id or by email |\n| `contacts remove` / `restore` | Remove (soft-delete) leads by id, and undo it |\n| `contacts pause` / `resume` | Pause and resume the campaign sequence of leads (pause is beta, `pause_leads` flag) |\n| `contacts tag` / `untag` | Add or remove a tag on leads by id |\n| `contacts export` | Download a campaign\'s leads as one CSV file |\n\nAll of them except `export` are batch commands: without `--yes` they print a plan and change nothing. [Batch runs](batch.md) explains plans, run folders, results files and resume.\n\n## Import a CSV into a campaign\n\n```bash\nkenso contacts import --template\nkenso contacts import --campaign 812 --file leads.csv --dry-run\nkenso contacts import --campaign 812 --file leads.csv --yes\n```\n\n`--template` prints the accepted columns. A minimal file:\n\n```csv\nemail,firstName,lastName,companyName,Pain Point\nann@acme.io,Ann,Lee,Acme,Slow onboarding\ncid@globex.com,Cid,Moe,Globex,Churn\n```\n\n### Columns\n\nHeaders are matched without regard to case, spaces or punctuation. The known fields and the other headers each one reads:\n\n| Field | Also read from | Rule |\n|---|---|---|\n| `email` | email address, e-mail, work email | Required, unless the campaign is LinkedIn-only and the row has a linkedin.com/in/ URL |\n| `firstName` | first name, first, given name | Required |\n| `lastName` | last name, last, surname, family name | Required |\n| `title` | job title, position, role | Optional |\n| `linkedInUrl` | linkedin, linkedin url, linkedin profile, profile url | Optional |\n| `companyName` | company, organization, account, account name | `companyName` or `companyDomain` is required |\n| `companyDomain` | website, domain, company website, url | `companyName` or `companyDomain` is required |\n\nEvery other column becomes a custom variable, named like its header in snake_case: a `Pain Point` column fills `{{pain_point}}` in your sequence. Names Kenso keeps for its own fields (`city`, `country`, `phone_number` and others) are not sent: the CLI warns and the plan lists them under `droppedColumns`. Rename them with `--map`.\n\n### Cells\n\n- A blank cell is never sent, so importing a file never erases data on a lead that is already in the campaign.\n- The literal value `null` clears a custom variable.\n\n### Rename or drop columns with --map\n\n`--map` takes a JSON object, `@file` or `-` for stdin. A value names the target field, and `null` drops the column:\n\n```bash\nkenso contacts import --campaign 812 --file leads.csv --map \'{"Pain": "customVariables.pain_point", "Notes": null}\' --yes\n```\n\n### JSONL\n\nEach line is one lead. Keys work like CSV headers, and a `customVariables` object is spread into custom variables:\n\n```json\n{"email":"ann@acme.io","firstName":"Ann","lastName":"Lee","companyName":"Acme","customVariables":{"pain_point":"Slow onboarding"}}\n```\n\n```bash\nkenso contacts import --campaign 812 --file leads.jsonl --yes\ncat leads.jsonl | kenso contacts import --campaign 812 --file - --yes\n```\n\nA run read from stdin cannot be resumed with `kenso runs resume`, so prefer a file for large imports.\n\n### What the CLI checks before sending\n\nEvery row is checked locally first. A row that fails goes to `invalid.csv` with one of these reasons and is not sent:\n\n| Reason | Meaning |\n|---|---|\n| `missing_email` | The row has no email (and is not a LinkedIn-only lead) |\n| `invalid_email` | The email is not a valid address |\n| `missing_first_name` | The row has no first name |\n| `missing_last_name` | The row has no last name |\n| `missing_company` | The row has neither a company name nor a company domain |\n| `sales_navigator_url` | An email-less row whose LinkedIn URL is a Sales Navigator link: add the email or a linkedin.com/in/ URL |\n| `duplicate_in_file` | An earlier row of the file has the same email |\n\nKenso stays the authority and checks every row again.\n\n### What the dry run shows\n\nThe dry run maps the columns, checks the rows, reads the campaign, and asks for a credit estimate (`credit-estimates create`, kind `leads`). Its plan holds `rows`, `valid`, `invalid`, `mapping`, `customVariableKeys`, `estimate`, `credits` (`needed`, `available`, `sufficient`) and `next`, the exact apply command. If the estimate call fails, the plan still prints with `"estimate":null` and an `estimateError`.\n\n### How rows are sent\n\nThe CLI sends 1,000 rows per call to `POST /v1/campaigns/{campaignId}/contacts`, one call at a time per campaign, because two overlapping upserts of the same email could create duplicates. After a 5xx answer or a dropped connection it does not retry right away: it waits 130 seconds, so the first request has finished on the server, then resends the chunk once. Rows the first request saved come back `updated`.\n\n### Reading the result\n\nEach row in `results.csv` gets one status:\n\n| Status | Meaning |\n|---|---|\n| `created` | A new lead in the campaign |\n| `updated` | The lead was already there and was updated |\n| `skipped` | Kenso kept the lead out on purpose. `reason` is `duplicate`, `blocklisted`, `already_contacted`, `excluded` or another code |\n| `failed` | Kenso refused the row (`reason` such as `invalid_email` or `validation`, and an `error` message), or the call failed |\n| `invalid` | The CLI refused the row before sending it (see the table above) |\n\nA saved lead that cannot be emailed yet has `sendable` false, and the summary counts those leads in `notSendable` with a warning on stderr.\n\nExit codes: 0 when no row failed (skipped rows are not failures), 5 when some rows failed or were invalid, 6 when the plan\'s or campaign\'s lead limit stopped the run (`limit_reached`; raise the limit, then rerun the same command to send the rest).\n\n## Import a very large file on the server\n\nFor up to 100,000 rows, `--server-import` uploads the CSV as one server import job and waits for it (default 30 minutes, `--timeout`). It reports the job\'s counts but no per-row results.\n\n```bash\nkenso contacts import --campaign 812 --file big.csv --server-import --yes\nkenso contacts import --campaign 812 --file big.csv --server-import --timeout 1h --yes\n```\n\n## Update leads in bulk\n\n```bash\nkenso contacts update --file edits.csv --match id\nkenso contacts update --file edits.csv --match id --yes\nkenso contacts update --file edits.csv --match email --campaign 812 --yes\n```\n\n- `--match id` reads the contact id from an `id` or `contactId` column.\n- `--match email` finds each email among the leads of `--campaign`. An email it cannot find is `not_found`. The email column only finds the lead; it is never sent as an edit.\n- A column that is absent, or a blank cell, leaves that field unchanged. The literal `null` clears it.\n- The lead fields you can edit are `firstName`, `lastName`, `title`, `linkedInUrl`, and `email` with `--match id`. `companyName` and `companyDomain` cannot change here and are not sent. Every other column is a custom variable, mapped the same way as in an import, and `--map` works here too.\n- A row with nothing to change is `skipped` with reason `no_changes` and is not sent. Rows go 1,000 per call to `POST /v1/contacts/bulk-update`.\n\nTo edit one lead, use a one-row file from stdin:\n\n```bash\nprintf \'id,title\\n9001,VP Sales\\n\' | kenso contacts update --file - --match id --yes\n```\n\n## Remove, restore, pause and resume\n\nThese four take contact ids from `--ids` (one id per line, or a CSV or JSONL file with an `id` or `contactId` column, or `-` for stdin), or from a search with `--where` (remove, pause and resume). `--where` takes the body of `POST /v1/contacts/search` as JSON, `@file` or `-`, and does the search itself: its plan shows how many contacts match and the first ids, so run no separate search or list first. `q` matches names and email addresses only; a company is a `filters` condition on `ContactCompany.Name` (`operator` 2 is contains; `kenso describe contacts search` lists the fields and operators).\n\n```bash\nkenso contacts remove --ids ids.txt --dry-run\nkenso contacts remove --ids ids.txt --yes\nkenso contacts remove --where \'{"campaignIds": [812], "filters": [{"conditions": {"field": "ContactCompany.Name", "operator": 2, "value": "acme"}}]}\' --dry-run\nkenso contacts remove --where \'{"campaignIds": [812], "filters": [{"conditions": {"field": "ContactCompany.Name", "operator": 2, "value": "acme"}}]}\' --yes\nkenso contacts remove --ids big.csv --confirm 2500 --yes\n```\n\n- Without `--yes`, the plan prints the count and the first ids.\n- Removing more than 1,000 contacts also needs `--confirm <count>`, and the count must match. The plan\'s `next` already carries the right `--confirm`.\n- Each id comes back `ok`, `skipped` when there was nothing to do (for example `not_deleted` when you restore a contact that is not removed), `not_found` or `failed`.\n- The remove summary carries the undo command, `"undo":"kenso contacts restore --run r_ubqu7wtx"`.\n\n```bash\nkenso contacts restore --run r_ubqu7wtx --yes\nkenso contacts restore --ids ids.txt --yes\nkenso contacts pause --where \'{"campaignIds": [812]}\' --yes\nkenso contacts resume --ids ids.txt --yes\n```\n\n`restore` takes `--ids` or `--run <runId>` of an earlier remove, not `--where`: a search cannot find removed contacts. `pause` is a beta command for workspaces with the `pause_leads` flag.\n\n## Tag and untag\n\n```bash\nkenso tags list\nkenso contacts tag --tag 31 --ids ids.txt --yes\nkenso contacts untag --tag 31 --ids ids.txt --yes\n```\n\n`tag` calls `POST /v1/tags/{tagId}/assignments` once per contact. A contact that already has the tag is `skipped` with reason `already_tagged`.\n\n## Export a campaign\'s leads\n\n```bash\nkenso contacts export --campaign 812 --out contacts.csv\nkenso contacts export --campaign 812 --wait\n```\n\nWith `--out`, the CLI creates the export, waits for the job (default 30 minutes, `--timeout`) and writes one CSV file (mode 0600). Without `--out` it prints the export job; `--wait` waits for it, or run the `next` it prints, `kenso jobs wait <jobId> --type contact_export`.\n\n## Recipes\n\n**Tag every lead of a campaign.** List the ids, then tag them:\n\n```bash\nkenso contacts list --campaign-id 812 --all -o ids > ids.txt\nkenso contacts tag --tag 31 --ids ids.txt --yes\n```\n\n**Fix the invalid rows and send them.** `invalid.csv` keeps each refused row\'s number and reason. Fix those rows in a new file and import it. Rerunning the original file also works: rows Kenso already saved come back `updated` or `skipped`.\n\n**Retry only what failed.** Filter `results.csv` to the `failed` rows, take those rows from the input by `row` number, and import that file.\n\n**Pause leads who replied elsewhere.** Plan with the filter (the plan shows the count), then apply:\n\n```bash\nkenso contacts pause --where @replied.json\nkenso contacts pause --where @replied.json --yes\n```\n\n**Edit one field on many leads by email.**\n\n```bash\nkenso contacts update --file titles.csv --match email --campaign 812 --dry-run\nkenso contacts update --file titles.csv --match email --campaign 812 --yes\n```\n';
9481
9528
  }
9482
9529
  });
9483
9530
 
@@ -9509,7 +9556,7 @@ var init_studio = __esm({
9509
9556
  var SKILL_default;
9510
9557
  var init_SKILL = __esm({
9511
9558
  "skill/kenso-cli/SKILL.md"() {
9512
- SKILL_default = '---\nname: kenso-cli\ndescription: The Kenso CLI (kenso) for work from a file or over about 50 records - import, update, remove, pause, tag or export leads, import CRM accounts and people, edit Studio templates as local files and fill them, run an API operation per file row. For one record or a question, use the Kenso MCP.\n---\n\n1. Every write runs first with `--dry-run`, never `--yes`; apply with the plan\'s `next`. `--yes` at once only to continue an interrupted run or apply what the person confirmed.\n2. Many rows are one command with the file as it is (`--file`, `--ids`, `--each`), never a command per row. Run kenso only: no python, awk or shell tests, not even on a file or variable.\n3. Errors are JSON on stderr: follow `next`. `--force` only with the person\'s OK.\n4. Ids are flags: `--campaign 812` on contacts import, update, export; `--campaign-id 812` on API commands (`contacts list`). `--workspace <id>`; by name `kenso workspace list --q acme`.\n\n```bash\nkenso contacts import --campaign 812 --file leads.csv --dry-run # skips leads already in; --map \'{"Pain": "customVariables.pain_point", "Notes": null}\'; over 200 MB --server-import\nkenso contacts update --file edits.csv --match id --dry-run # also one contact; or --match email --campaign 812\nkenso contacts remove --ids ids.txt --dry-run # same for pause, resume, restore, tag --tag 31; over 1,000 add --confirm <count>\nkenso contacts remove --where \'{"campaignIds": [812], "q": "acme"}\' --dry-run # also pause, resume; the plan shows the matches: no search first\nkenso contacts list --campaign-id 812 --all -o ids > ids.txt # tag and restore take --ids only: list the ids first\nkenso contacts export --campaign 812 --out leads.csv # a segment: kenso contact-exports create --segment-id 55\nkenso runs list # an interrupted run (terminal lost): find it, then kenso runs resume <runId>. A changed file is a new run\nkenso accounts import --file accounts.csv --dry-run # companies; people (any file of people) go in with kenso people import --file people.csv\nkenso studio push 12 --dir ./tpl # to preview local edits push first (draft only; live needs --publish), then studio preview 12\nkenso studio fill 12 --accounts ids.txt --dry-run # or --campaign 10546 --contacts leads.csv\nkenso campaigns update --campaign-id 812 --name Q4 --dry-run # any API operation; --each rows.csv for many\nkenso mcp call list_skills\n```\n\n`accounts import` charges per new account, `studio fill` per page (again for the same ids in another file): show the estimate first. `pinned_by_user`: with the person\'s OK, `--overwrite-pinned`. Workflows: `kenso workflows brief` first; secrets only as `{{secrets.NAME}}`. Key: `KENSO_API_KEY`, or any variable piped as the first command, no check or printenv (an empty key is refused): `printf \'%s\' "$KEY" | kenso auth login --with-key`; never in a flag, output or file. `kenso api` only when no command fits. Flags: `kenso <resource> <action> --help`. More: `kenso docs leads|batch|crm|studio`.\n';
9559
+ SKILL_default = '---\nname: kenso-cli\ndescription: The Kenso CLI (kenso) for work from a file (of any size, even a few ids) or over about 50 records - import, update, remove (delete), pause, tag or export leads, import CRM accounts and people, edit Studio templates as local files and fill them, run an API operation per file row, resume an interrupted run, sign in with a key from a variable (pipe it to kenso auth login --with-key, no check first). For records listed in the message (under about 50), one record or a question, use the Kenso MCP.\n---\n\n1. Every write runs first with `--dry-run`; apply with the plan\'s `next`. `--yes` at once only to continue an interrupted run or apply what the person confirmed.\n2. Many rows: one command on the file as it is (`--file`, `--ids`, `--each`). Run kenso only: no python, awk or shell tests, not even on a file or variable.\n3. Any output\'s `next` is the command to run; errors are JSON on stderr. `--force` only with the person\'s OK.\n4. Ids are flags: `--campaign 812` on contacts import, update, export; `--campaign-id 812` on API commands. `--workspace <id>`; by name `kenso workspace list --q acme`. Other commands: `kenso --help`; `kenso api <METHOD> <path>` only when none fits.\n\n```bash\nkenso contacts import --campaign 812 --file leads.csv --dry-run # skips leads already in; --map \'{"Pain": "customVariables.pain_point", "Notes": null}\'; over 200 MB --server-import\nkenso contacts update --file edits.csv --match id --dry-run # also one lead; or --match email --campaign 812\nkenso contacts remove --ids ids.txt --dry-run # also pause, resume, restore; over 1,000: --confirm <count>\nkenso contacts remove --where \'{"campaignIds": [812], "filters": [{"conditions": {"field": "ContactCompany.Name", "operator": 2, "value": "acme"}}]}\' --dry-run # also pause, resume; 2 = contains; no search first\nkenso contacts tag --tag 31 --ids ids.txt --dry-run # tag, restore: --ids only, so first kenso contacts list --campaign-id 812 --all -o ids > ids.txt\nkenso contact-imports create --source campaign --source-campaign-id 10601 --campaign-id 812 --dry-run # copy another campaign\'s leads in\nkenso contacts export --campaign 812 --out leads.csv # a segment: kenso contact-exports create --segment-id 55\nkenso runs resume <runId> # an interrupted run; terminal lost: kenso runs list finds it. A changed file is a new run\nkenso accounts import --file accounts.csv --dry-run # companies; a file of people: kenso people import --file people.csv\nkenso studio push 12 --dir ./tpl # to preview local edits push first (draft; live needs --publish), then studio preview 12\nkenso studio fill 12 --accounts ids.txt --dry-run # or --campaign 10546 --contacts leads.csv\nkenso opportunities update --each rows.csv --dry-run # any API command on a file of rows (CSV or JSONL; keys fill flags and body). One record: kenso campaigns update --campaign-id 812 --name Q4 --dry-run\nkenso mcp call list_skills # only to list Ken\'s skills\n```\n\nCharges: `accounts import` per new account, `studio fill` per page (again for ids from another file): show the estimate. `pinned_by_user`: with the person\'s OK, rerun with `--overwrite-pinned`. Workflows: `kenso workflows brief` first; secrets only as `{{secrets.NAME}}`. Key: `KENSO_API_KEY`, or pipe a variable, no check: `printf \'%s\' "$KEY" | kenso auth login --with-key`; never in a flag, output or file.\n';
9513
9560
  }
9514
9561
  });
9515
9562
 
@@ -9603,7 +9650,8 @@ var init_docs = __esm({
9603
9650
  command11 = {
9604
9651
  resource: "docs",
9605
9652
  action: "",
9606
- summary: "Read the CLI docs offline: the topic list, or one topic as Markdown",
9653
+ summary: "Offline docs: the topic list, or one topic",
9654
+ brief: "",
9607
9655
  description: "Without a topic, lists the topics with one line each (NDJSON when piped). With a topic, prints that page's Markdown to stdout. The same pages are in the package's docs/ folder.",
9608
9656
  flags: {},
9609
9657
  positionals: [{ name: "topic", description: `One of ${NAMES}` }],
@@ -9690,7 +9738,8 @@ var init_doctor = __esm({
9690
9738
  command12 = {
9691
9739
  resource: "doctor",
9692
9740
  action: "",
9693
- summary: "Check the setup: Node, the config file, the API and the key",
9741
+ summary: "Check Node, the config, the API and the key",
9742
+ brief: "",
9694
9743
  description: "Prints the version, Node, platform, config location, profile, key source, API URL, workspace and detected agent, plus checks node_version, config_permissions, api_reachable and key_valid. Exits 0 when every check passes, 3 when the key check fails, else 1.",
9695
9744
  flags: {},
9696
9745
  examples: ["kenso doctor", "kenso doctor --profile client-a --output json"],
@@ -10006,13 +10055,14 @@ var init_call = __esm({
10006
10055
  command13 = {
10007
10056
  resource: "mcp",
10008
10057
  action: "call",
10009
- summary: "Call a hosted Kenso MCP tool, such as list_skills or load_skill",
10010
- description: "Calls one Kenso MCP tool with the same API key and prints its result: structuredContent when the tool returns it, else its text parsed as JSON, else the text. Covers MCP-only tools, such as the skills (list_skills, load_skill). A tool error exits 1 with the tool's message. A facade tool takes its operation with --operation.",
10058
+ summary: "Call a hosted Kenso MCP tool, such as list_skills (no mcp tools first)",
10059
+ brief: "list_skills runs directly",
10060
+ description: "Calls one Kenso MCP tool with the same API key and prints its result: structuredContent when the tool returns it, else its text parsed as JSON, else the text. Covers MCP-only tools, such as the skills: list_skills and load_skill take no kenso mcp tools first. A tool error exits 1 with the tool's message. A facade tool takes its operation with --operation.",
10011
10061
  flags: {
10012
10062
  operation: { type: "string", description: "The facade operation, for example list" },
10013
10063
  args: { type: "string", description: "Tool arguments as a JSON object: inline, @file or - for stdin" }
10014
10064
  },
10015
- positionals: [{ name: "tool", required: true, description: "The tool name (see kenso mcp tools)" }],
10065
+ positionals: [{ name: "tool", required: true, description: "The tool name: list_skills, load_skill, or one from kenso mcp tools" }],
10016
10066
  examples: [`kenso mcp call contacts --operation list --args '{"limit":5}'`, "kenso mcp call list_skills", `kenso mcp call load_skill --args '{"name":"create-campaign"}'`],
10017
10067
  operationIds: [],
10018
10068
  async run(ctx, { flags, positionals }) {
@@ -10069,6 +10119,7 @@ var init_tools = __esm({
10069
10119
  resource: "mcp",
10070
10120
  action: "tools",
10071
10121
  summary: "List the hosted Kenso MCP's tools",
10122
+ brief: "only to find a tool name",
10072
10123
  description: "Lists every tool of the Kenso MCP (KENSO_MCP_URL, default https://mcp.ken.so/mcp) as { name, description }, with the same API key. Call one with kenso mcp call <tool>.",
10073
10124
  flags: {},
10074
10125
  examples: ["kenso mcp tools", "kenso mcp tools --fields name"],
@@ -10202,6 +10253,7 @@ var init_import3 = __esm({
10202
10253
  resource: "people",
10203
10254
  action: "import",
10204
10255
  summary: "Create or match people from a CSV or JSONL file, optionally onto accounts",
10256
+ brief: "a file of people",
10205
10257
  description: "Sends the rows to people.batch, 200 per call. Columns map by name (case, spaces and dashes ignored): email, fullName (or name), firstName, lastName, linkedinUrl (or linkedin, profileUrl), title, accountDomain (or domain, website, companyDomain), accountId, role and priority. A person needs a name: fullName, or firstName and lastName. Other columns are ignored with a warning. People already in the workspace are matched by email, then LinkedIn URL, so running the same file again creates nothing. It never creates accounts: a row whose accountDomain matches no account fails with account_not_found. Without --yes it only prints the plan.",
10206
10258
  flags: {
10207
10259
  file: { type: "string", required: true, description: "The CSV or JSONL file, or - for stdin" },
@@ -10210,7 +10262,7 @@ var init_import3 = __esm({
10210
10262
  out: { type: "string", description: "Keep the run's files in this directory instead of ~/.kenso/runs" }
10211
10263
  },
10212
10264
  examples: [
10213
- "kenso people import --file people.csv",
10265
+ "kenso people import --file people.csv --dry-run",
10214
10266
  "kenso people import --file people.csv --find-emails --yes",
10215
10267
  "kenso people import --file people.jsonl --dry-run"
10216
10268
  ],
@@ -10291,7 +10343,8 @@ var init_list = __esm({
10291
10343
  command16 = {
10292
10344
  resource: "runs",
10293
10345
  action: "list",
10294
- summary: "List batch runs, newest first",
10346
+ summary: "List batch runs, newest first (finds an interrupted run)",
10347
+ brief: "finds an interrupted run",
10295
10348
  description: "Lists the runs under the runs directory (KENSO_RUNS_DIR, else ~/.kenso/runs). Needs no API key.",
10296
10349
  flags: {
10297
10350
  limit: { type: "string", default: "20", description: "How many runs to list, 1-1000 (default 20)" }
@@ -10337,7 +10390,7 @@ var init_show = __esm({
10337
10390
  command17 = {
10338
10391
  resource: "runs",
10339
10392
  action: "show",
10340
- summary: "Show one batch run: its run.json, its last summary and its files",
10393
+ summary: "Show one batch run and its files",
10341
10394
  flags: {},
10342
10395
  positionals: [{ name: "runId", required: true, description: "The run id (see kenso runs list)" }],
10343
10396
  examples: ["kenso runs show r_ab3dxk2q"],
@@ -10430,7 +10483,8 @@ var init_resume = __esm({
10430
10483
  command18 = {
10431
10484
  resource: "runs",
10432
10485
  action: "resume",
10433
- summary: "Continue a batch run: rerun its command with --yes",
10486
+ summary: "Continue an interrupted batch run (its command with --yes, no dry run)",
10487
+ brief: "with --yes, no dry run",
10434
10488
  description: "Rebuilds the run's command from run.json and runs it with --yes from the directory it started in. The engine finds the run by its identity and skips the chunks already done. Refused when the input file is gone or changed.",
10435
10489
  flags: {},
10436
10490
  positionals: [{ name: "runId", required: true, description: "The run id (see kenso runs list)" }],
@@ -10558,7 +10612,8 @@ var init_install = __esm({
10558
10612
  command19 = {
10559
10613
  resource: "skill",
10560
10614
  action: "install",
10561
- summary: "Install the kenso-cli Agent Skill for Claude Code, Codex or Cursor",
10615
+ summary: "Install the kenso-cli skill for Claude Code, Codex or Cursor",
10616
+ brief: "",
10562
10617
  description: "Writes the Agent Skill bundled with this CLI to SKILL.md in each agent's skills folder: ~/.claude/skills/kenso-cli (Claude Code), ~/.agents/skills/kenso-cli (Codex) and ~/.cursor/skills/kenso-cli (Cursor). The default client is the coding agent running the command, else all three. --dir writes into that folder instead. A different SKILL.md already there is replaced only with --force (exit 7 without it). --dry-run prints the plan and writes nothing.",
10563
10618
  flags: {
10564
10619
  client: { type: "string", description: "claude-code, codex, cursor or all (default: the detected agent, else all)" },
@@ -11156,9 +11211,10 @@ var init_preview = __esm({
11156
11211
  resource: "studio",
11157
11212
  action: "preview",
11158
11213
  summary: "Render a saved version for up to 3 accounts; for local edits, studio push them to the draft first",
11214
+ brief: "push local edits first",
11159
11215
  description: "Calls asset-templates.preview: renders the draft (else the latest published version, or --version-id) for --account-ids (up to 3; default the 3 most recently touched accounts, else the bundle's samples) and prints each preview's link, screenshots, console errors and link issues. It takes up to a minute per account. The preview renders what is saved, not local files: with --dir it compares the folder to that version and warns when they differ (run kenso studio push first).",
11160
11216
  flags: {
11161
- dir: { type: "string", description: "A template folder to compare with the version being rendered" },
11217
+ dir: { type: "string", description: "A template folder to compare with the version being rendered; it renders no local edits (studio push them first)" },
11162
11218
  "account-ids": { type: "string", description: "Up to 3 account ids, separated by commas" },
11163
11219
  mode: { type: "string", description: "render (default) or interact" },
11164
11220
  sample: { type: "string", description: "A bundle sample to render, such as samples/acme.json" },
@@ -11421,6 +11477,7 @@ var init_fill = __esm({
11421
11477
  resource: "studio",
11422
11478
  action: "fill",
11423
11479
  summary: "Fill a template on many accounts or campaign leads (estimate first)",
11480
+ brief: "estimate first",
11424
11481
  description: "Reads ids one per line, or a CSV/JSONL with an id column (--column, else id, else contactId), from --accounts, or from --contacts with --campaign for a campaign's leads. It always asks for the cost first (asset-runs.estimate) and prints it; --yes then creates one fill run per 500 ids, one after another, and --wait follows each run (every 5 s) until it completes, fails or is cancelled. The summary lists the run ids and their states. A fill charges for every page it builds, so the same ids are not filled twice: an unfinished fill continues, and a finished one is refused (exit 7) with how to retry only the failed ids.",
11425
11482
  flags: {
11426
11483
  accounts: { type: "string", description: "Account ids: a file, or - for stdin" },
@@ -11432,7 +11489,7 @@ var init_fill = __esm({
11432
11489
  },
11433
11490
  positionals: [TEMPLATE_ID],
11434
11491
  examples: [
11435
- "kenso studio fill 12 --accounts accounts.txt",
11492
+ "kenso studio fill 12 --accounts accounts.txt --dry-run",
11436
11493
  "kenso studio fill 12 --accounts accounts.csv --column accountId --yes --wait",
11437
11494
  "kenso studio fill 12 --campaign 345 --contacts leads.csv --yes"
11438
11495
  ],
@@ -11563,7 +11620,7 @@ var init_use = __esm({
11563
11620
  command26 = {
11564
11621
  resource: "workspace",
11565
11622
  action: "use",
11566
- summary: "Make a workspace the default for the profile",
11623
+ summary: "Set the profile's default workspace",
11567
11624
  description: "Checks the workspace with GET /v1/workspaces/{workspaceId}, then saves it to the profile (--profile, KENSO_PROFILE or the active one). --workspace and KENSO_WORKSPACE_ID still win for a single command.",
11568
11625
  flags: {},
11569
11626
  positionals: [{ name: "workspaceId", required: true, description: "The workspace id (see kenso workspace list)" }],
@@ -11721,6 +11778,24 @@ function wrap(text2, indent) {
11721
11778
  if (line) out.push(indent + line);
11722
11779
  return out.join("\n");
11723
11780
  }
11781
+ function wrapCell(line, at) {
11782
+ const indent = " ".repeat(at);
11783
+ const out = [];
11784
+ let current = line.slice(0, at).trimEnd();
11785
+ let first = true;
11786
+ for (const word of line.slice(at).split(" ")) {
11787
+ const start = first ? `${line.slice(0, at)}` : `${indent}`;
11788
+ if (!first && current.length + 1 + word.length > 110) {
11789
+ out.push(current);
11790
+ current = `${indent}${word}`;
11791
+ } else {
11792
+ current = first ? `${start}${word}` : `${current} ${word}`;
11793
+ }
11794
+ first = false;
11795
+ }
11796
+ out.push(current);
11797
+ return out.join("\n");
11798
+ }
11724
11799
  function columns(rows, indent = " ", maxWidth = 28) {
11725
11800
  const width = Math.min(maxWidth, Math.max(0, ...rows.map(([left]) => left.length)));
11726
11801
  return rows.map(([left, right]) => `${indent}${left.padEnd(width)} ${right}`.trimEnd()).join("\n");
@@ -11752,35 +11827,59 @@ async function loadArea(resource) {
11752
11827
  }
11753
11828
  async function renderTopHelp(opts) {
11754
11829
  const lines = [
11755
- `kenso ${VERSION} - the Kenso CLI: bulk lead, CRM and Studio work for people and coding agents.`,
11830
+ `kenso ${VERSION}, the Kenso CLI. Usage: kenso <resource> <action> [flags]; add --help to any of them.`,
11756
11831
  "",
11757
- "Usage:",
11758
- " kenso <resource> <action> [flags]",
11759
- " kenso <resource> <action> --help",
11760
- "",
11761
- "Rules:",
11762
- " Run every write with --dry-run first and read the plan. Then apply: a batch command (import, update, remove,",
11763
- " tag, --each) with the plan's next (--yes); any other write without --dry-run.",
11764
- " Many rows are one command with the file as it is (--file, --ids, --each), never one command per row: kenso",
11765
- " checks and counts the rows. No scripts or shell tests.",
11766
- " API resource commands take ids as flags (kenso campaigns get --campaign-id 812), never as bare arguments.",
11767
- " An error is one JSON line on stderr: run its next.",
11832
+ "Rules: every write runs first with --dry-run. Then a batch command (import, update, remove, tag, --each) applies",
11833
+ "with the plan's next (--yes), any other write by running again without --dry-run. A file is one command as it is",
11834
+ "(--file, --ids, --each), never one per row; no python, awk or shell tests. Ids are flags (--campaign-id 812).",
11835
+ "Any output's next is the command to run; errors are JSON on stderr.",
11768
11836
  "",
11769
11837
  "Commands:"
11770
11838
  ];
11771
11839
  const getFeatures = lazyFeatures(opts);
11772
11840
  const hiddenSpecs = { count: 0, gates: /* @__PURE__ */ new Set(), names: [] };
11773
11841
  const rows = [];
11842
+ const also = [];
11774
11843
  for (const resource of Object.keys(handWritten).sort()) {
11775
- for (const spec of await visibleSpecs(await loadArea(resource), opts, getFeatures, hiddenSpecs)) {
11844
+ const specs = await visibleSpecs(await loadArea(resource), opts, getFeatures, hiddenSpecs);
11845
+ const withPositionals = (spec, name) => {
11776
11846
  const positionals = (spec.positionals ?? []).map((p) => p.required ? `<${p.name}>` : `[${p.name}]`).join(" ");
11777
- const label = `${commandName(spec).slice("kenso ".length)}${positionals ? ` ${positionals}` : ""}`;
11778
- rows.push([label, `${spec.summary}${spec.beta ? " (beta)" : ""}`]);
11847
+ return `${name}${positionals ? ` ${positionals}` : ""}`;
11848
+ };
11849
+ if (opts.allCommands) {
11850
+ for (const spec of specs) {
11851
+ rows.push([withPositionals(spec, commandName(spec).slice("kenso ".length)), `${spec.summary}${spec.beta ? " (beta)" : ""}`]);
11852
+ }
11853
+ continue;
11779
11854
  }
11855
+ const groups = /* @__PURE__ */ new Map();
11856
+ for (const spec of specs) {
11857
+ if (!spec.action) {
11858
+ if (spec.brief !== void 0) also.push(`${withPositionals(spec, spec.resource)}${spec.brief ? ` (${spec.brief})` : ""}`);
11859
+ else rows.push([withPositionals(spec, spec.resource), spec.summary]);
11860
+ continue;
11861
+ }
11862
+ const hint = `${spec.brief ? ` (${spec.brief})` : ""}${spec.beta ? " (beta)" : ""}`;
11863
+ const names = groups.get(hint) ?? [];
11864
+ names.push(withPositionals(spec, spec.action));
11865
+ groups.set(hint, names);
11866
+ }
11867
+ const actions15 = [...groups.entries()].map(([hint, names]) => `${names.join(", ")}${hint}`);
11868
+ if (actions15.length > 0) rows.push([resource, actions15.join(", ")]);
11780
11869
  }
11781
- lines.push(columns(rows));
11870
+ const at = 4 + Math.min(22, Math.max(0, ...rows.map(([left]) => left.length)));
11871
+ lines.push(
11872
+ columns(rows, " ", 22).split("\n").map((line) => line.length > 110 && line.slice(at - 2, at) === " " && line[at] !== " " ? wrapCell(line, at) : line).join("\n")
11873
+ );
11874
+ if (also.length > 0) lines.push(wrap(`Also: ${also.join(", ")}.`, " "));
11782
11875
  if (hiddenSpecs.names.length > 0) {
11783
- lines.push(` Beta, hidden for this workspace (they still run; the server decides): ${hiddenSpecs.names.sort().join(", ")}.`);
11876
+ const byArea = /* @__PURE__ */ new Map();
11877
+ for (const name of hiddenSpecs.names.sort()) {
11878
+ const [area = "", action = ""] = name.split(" ");
11879
+ byArea.set(area, [...byArea.get(area) ?? [], action]);
11880
+ }
11881
+ const named = [...byArea.entries()].map(([area, actions15]) => `${area} ${actions15.join("|")}`);
11882
+ lines.push(wrap(`Beta, hidden here but they run (the server decides): ${named.join(", ")}.`, " "));
11784
11883
  }
11785
11884
  const generated = await listGenerated({ features: opts.allCommands ? [] : getFeatures, env: opts.env });
11786
11885
  const visible = generated.filter((g) => opts.allCommands || !g.beta);
@@ -11798,18 +11897,17 @@ async function renderTopHelp(opts) {
11798
11897
  } else {
11799
11898
  const names = new Set(sorted.map(([resource]) => resource));
11800
11899
  const common = COMMON_RESOURCES.filter((r) => names.has(r)).join(", ");
11900
+ const hidden = generated.length - visible.length + hiddenSpecs.count;
11801
11901
  lines.push(
11802
11902
  "",
11803
- `API resources (${names.size}), one per /v1 route group and named like it (/v1/contact-exports: kenso contact-exports).`,
11903
+ `API resources (${names.size}), named like their /v1 route (kenso contact-exports); kenso <resource> --help lists actions.`,
11804
11904
  wrap(`Common: ${common}.`, " "),
11805
- " kenso <resource> --help lists its actions; kenso --help --all-commands lists every resource."
11905
+ ` Every resource: kenso --help --all-commands${hidden > 0 ? ` (also shows ${hidden} hidden beta commands)` : ""}.`
11806
11906
  );
11807
11907
  }
11808
11908
  }
11809
- const hidden = generated.length - visible.length + hiddenSpecs.count;
11810
- if (hidden > 0) lines.push("", `${hidden} beta command${hidden === 1 ? " is" : "s are"} hidden; --all-commands shows them.`);
11811
11909
  if (opts.allCommands) lines.push("", "Global flags:", columns(GLOBAL_FLAG_HELP), "", EXIT_CODES);
11812
- else lines.push("", GLOBAL_FLAGS_LINE, EXIT_CODES);
11910
+ else lines.push("", GLOBAL_FLAGS_LINE);
11813
11911
  return withAgentBlock(lines.join("\n"), opts.agent);
11814
11912
  }
11815
11913
  async function renderResourceHelp(resource, opts) {
@@ -11851,6 +11949,7 @@ async function renderResourceHelp(resource, opts) {
11851
11949
  lines.push("", `Beta, hidden for this workspace (they still run; the server decides): ${hiddenActions.join(", ")}.`);
11852
11950
  }
11853
11951
  lines.push("", `Run kenso ${resource} <action> --help for an action's flags.`);
11952
+ if (all.length === 0) lines.push("A file of rows is one command, never one per row: kenso " + resource + " <action> --each <file> --dry-run.");
11854
11953
  return withAgentBlock(lines.join("\n"), opts.agent);
11855
11954
  }
11856
11955
  function renderCommandHelp(spec, opts) {
@@ -11860,13 +11959,13 @@ function renderCommandHelp(spec, opts) {
11860
11959
  lines.push("", "Arguments:", columns(spec.positionals.map((p) => [`<${p.name}>`, `${p.description}${p.required ? "" : " (optional)"}`])));
11861
11960
  }
11862
11961
  const paired = (name) => spec.flags[name]?.type === "boolean" && spec.flags[`no-${name}`]?.type === "boolean";
11863
- const flagRows = Object.entries(spec.flags).filter(([name]) => !(name.startsWith("no-") && paired(name.slice(3)))).sort(([a], [b]) => a.localeCompare(b)).map(([name, flag]) => {
11962
+ const flagRows = Object.entries(spec.flags).filter(([name, flag]) => !flag.helpHidden && !(name.startsWith("no-") && paired(name.slice(3)))).sort(([a], [b]) => a.localeCompare(b)).map(([name, flag]) => {
11864
11963
  const notes = [flag.required ? "required" : "", flag.default !== void 0 ? `default ${String(flag.default)}` : ""].filter(Boolean);
11865
11964
  const label = paired(name) ? flagLabel(name, flag).replace(`--${name}`, `--[no-]${name}`) : flagLabel(name, flag);
11866
11965
  return [label, `${flag.description}${notes.length ? ` (${notes.join(", ")})` : ""}`];
11867
11966
  });
11868
11967
  if (flagRows.length > 0) lines.push("", "Flags:", columns(flagRows));
11869
- lines.push("", GLOBAL_FLAGS_LINE);
11968
+ lines.push("", ...spec.helpFooter ?? [], GLOBAL_FLAGS_LINE);
11870
11969
  if (spec.examples.length > 0) lines.push("", "Examples:", ...spec.examples.map((e) => ` ${e}`));
11871
11970
  return withAgentBlock(lines.join("\n"), opts.agent);
11872
11971
  }
@@ -11894,7 +11993,7 @@ var init_help = __esm({
11894
11993
  ["-h, --help", "Show help"],
11895
11994
  ["--version", "Print the CLI version (only on its own: kenso --version)"]
11896
11995
  ];
11897
- GLOBAL_FLAGS_LINE = "Global flags: --dry-run, -y/--yes, --workspace <id>, --profile, -o/--output json|ndjson|table|ids, --fields, --query, -v, --progress (kenso --help --all-commands explains them).";
11996
+ GLOBAL_FLAGS_LINE = "Global flags: --dry-run, -y/--yes, --workspace <id>, -o json|ndjson|table|ids, --fields, --query, -v.";
11898
11997
  COMMON_RESOURCES = [
11899
11998
  "accounts",
11900
11999
  "analytics",