kenso 0.1.11 → 0.1.13

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.11" : "0.0.0-dev";
2400
+ VERSION = true ? "0.1.13" : "0.0.0-dev";
2401
2401
  }
2402
2402
  });
2403
2403
 
@@ -6215,7 +6215,9 @@ var init_each = __esm({
6215
6215
  var execute_exports = {};
6216
6216
  __export(execute_exports, {
6217
6217
  DATA_KEY: () => DATA_KEY,
6218
+ HELP_NOTES_MAX: () => HELP_NOTES_MAX,
6218
6219
  buildRequest: () => buildRequest,
6220
+ helpNotes: () => helpNotes,
6219
6221
  operationExamples: () => operationExamples,
6220
6222
  operationFlags: () => operationFlags,
6221
6223
  operationToCommand: () => operationToCommand
@@ -6233,7 +6235,7 @@ function fileProp(op) {
6233
6235
  }
6234
6236
  function describeParam(param, location, list2) {
6235
6237
  const fallback = list2 && location === "query" && LIST_PARAM_TEXT[param.name] || `${param.name} (${location === "route" ? "path" : location}, ${param.type})`;
6236
- const base = param.description ?? fallback;
6238
+ const base = param.description?.replace(/\s*\n\s*/g, " ") ?? fallback;
6237
6239
  return param.enum ? `${base} One of: ${param.enum.join(", ")}.` : base;
6238
6240
  }
6239
6241
  function operationFlags(op) {
@@ -6309,9 +6311,9 @@ function usageLine(op) {
6309
6311
  function operationExamples(op) {
6310
6312
  const required = operationFlags(op).filter((f) => f.required && f.flag !== "file").map(flagUsage);
6311
6313
  const base = [commandOf(op), ...required].join(" ");
6312
- const examples = [base];
6313
- if (op.body?.format === "multipart") examples[0] = `${base} --file ./file.csv`;
6314
- else if (op.body) examples.push(`${base} --data @body.json`);
6314
+ const first = op.body?.format === "multipart" ? `${base} --file ./file.csv` : base;
6315
+ const examples = op.method === "GET" ? [first] : [`${first} --dry-run`, first];
6316
+ if (op.body && op.body.format !== "multipart") examples.push(`${base} --data @body.json`);
6315
6317
  if (op.list) examples.push(`${base} --all`);
6316
6318
  return examples;
6317
6319
  }
@@ -6520,6 +6522,15 @@ async function runOperation(ctx, op, flags) {
6520
6522
  ctx.output.object(unwrap(res.body));
6521
6523
  return ExitCode.Ok;
6522
6524
  }
6525
+ function helpNotes(op) {
6526
+ const text2 = (op.description ?? "").trim().replace(/\n[ \t]+/g, "\n");
6527
+ if (text2.length <= HELP_NOTES_MAX) return text2;
6528
+ const head = text2.slice(0, HELP_NOTES_MAX);
6529
+ 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}.`;
6533
+ }
6523
6534
  function operationToCommand(op) {
6524
6535
  const flags = {};
6525
6536
  for (const f of operationFlags(op)) {
@@ -6531,12 +6542,13 @@ function operationToCommand(op) {
6531
6542
  if (f.multiple) spec.multiple = true;
6532
6543
  flags[f.flag] = spec;
6533
6544
  }
6534
- const api = `API: ${op.method} ${op.path} (${op.id}).`;
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.";
6546
+ const api = `API: ${op.method} ${op.path} (${op.id}).${write2}`;
6535
6547
  return {
6536
6548
  resource: op.resource,
6537
6549
  action: op.action,
6538
6550
  summary: op.summary ?? `${op.method} ${op.path}`,
6539
- description: op.description ? `${op.description}
6551
+ description: op.description ? `${helpNotes(op)}
6540
6552
 
6541
6553
  ${api}` : api,
6542
6554
  flags,
@@ -6546,7 +6558,7 @@ ${api}` : api,
6546
6558
  run: (ctx, args) => runOperation(ctx, op, args.flags)
6547
6559
  };
6548
6560
  }
6549
- var DATA_KEY, SCALAR_TYPES, LIST_PARAM_TEXT;
6561
+ var DATA_KEY, SCALAR_TYPES, LIST_PARAM_TEXT, HELP_NOTES_MAX;
6550
6562
  var init_execute = __esm({
6551
6563
  "src/generated/execute.ts"() {
6552
6564
  "use strict";
@@ -6560,6 +6572,7 @@ var init_execute = __esm({
6560
6572
  limit: "Page size.",
6561
6573
  cursor: "Start at this cursor (the next: hint of a previous page)."
6562
6574
  };
6575
+ HELP_NOTES_MAX = 600;
6563
6576
  }
6564
6577
  });
6565
6578
 
@@ -6895,7 +6908,7 @@ var init_import = __esm({
6895
6908
  command = {
6896
6909
  resource: "accounts",
6897
6910
  action: "import",
6898
- summary: "Create accounts from a CSV or JSONL file (research is charged per created account)",
6911
+ summary: "Create accounts (companies) from a CSV or JSONL file, charged per created account; a file of people: people import",
6899
6912
  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.",
6900
6913
  flags: {
6901
6914
  file: { type: "string", required: true, description: "The CSV or JSONL file, or - for stdin" },
@@ -7307,7 +7320,7 @@ var init_login = __esm({
7307
7320
  resource: "auth",
7308
7321
  action: "login",
7309
7322
  summary: "Save an API key on this machine (--with-key reads it from stdin)",
7310
- 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.",
7323
+ 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).",
7311
7324
  flags: {
7312
7325
  "with-key": { type: "boolean", description: "Read the API key from stdin" },
7313
7326
  "device-name": { type: "string", description: "Name this machine on the browser sign-in page" },
@@ -8634,7 +8647,7 @@ var init_update = __esm({
8634
8647
  command8 = {
8635
8648
  resource: "contacts",
8636
8649
  action: "update",
8637
- summary: "Edit leads in bulk from a CSV or JSONL file",
8650
+ summary: "Edit leads from a CSV or JSONL file, matched by id or by email (also one lead)",
8638
8651
  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.",
8639
8652
  flags: {
8640
8653
  file: { type: "string", description: "CSV (with a header row) or JSONL file, or - for stdin" },
@@ -8907,20 +8920,20 @@ function bulkCommand(kind) {
8907
8920
  ids: { type: "string", description: "Contact ids: one per line, or a CSV/JSONL with an id column; - for stdin" }
8908
8921
  };
8909
8922
  if (kind === "restore") flags.run = { type: "string", description: "Restore what an earlier contacts remove run removed" };
8910
- else flags.where = { type: "string", description: "contacts search filters as JSON, @file or - (the body of POST /v1/contacts/search)" };
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" };
8911
8924
  if (kind === "remove") flags.confirm = { type: "string", description: "Required to remove more than 1,000 contacts: the exact count" };
8912
8925
  flags.out = OUT_FLAG;
8913
8926
  flags.concurrency = CONCURRENCY_FLAG;
8914
8927
  return {
8915
8928
  resource: "contacts",
8916
8929
  action: kind,
8917
- summary: `${route.verb} contacts by id, 1,000 per call`,
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`,
8918
8931
  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." : ""),
8919
8932
  flags,
8920
8933
  examples: [
8921
8934
  `kenso contacts ${kind} --ids ids.txt`,
8922
8935
  `kenso contacts ${kind} --ids ids.txt --yes`,
8923
- ...kind === "restore" ? ["kenso contacts restore --run r_ab3dxk2q --yes"] : [`kenso contacts ${kind} --where '{"campaignIds": [812], "q": "acme"}' --yes`],
8936
+ ...kind === "restore" ? ["kenso contacts restore --run r_ab3dxk2q --yes"] : [`kenso contacts ${kind} --where '{"campaignIds": [812], "q": "acme"}' --dry-run`],
8924
8937
  ...kind === "remove" ? ["kenso contacts remove --ids big.csv --confirm 2500 --yes"] : []
8925
8938
  ],
8926
8939
  ...kind === "pause" ? { beta: { featureGates: { all: ["pause_leads"] } } } : {},
@@ -9061,7 +9074,7 @@ function tagCommand(kind) {
9061
9074
  return {
9062
9075
  resource: "contacts",
9063
9076
  action: kind,
9064
- summary: kind === "tag" ? "Add a tag to contacts by id" : "Remove a tag from contacts by id",
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)`,
9065
9078
  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.",
9066
9079
  flags: {
9067
9080
  tag: { type: "string", description: "The tag id (see kenso tags list)" },
@@ -9184,7 +9197,7 @@ var init_export = __esm({
9184
9197
  command9 = {
9185
9198
  resource: "contacts",
9186
9199
  action: "export",
9187
- summary: "Export a campaign's contacts to a CSV file",
9200
+ summary: "Export a campaign's contacts to a CSV file (a segment: kenso contact-exports create)",
9188
9201
  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).",
9189
9202
  flags: {
9190
9203
  campaign: { type: "string", description: "The campaign id" },
@@ -9432,7 +9445,7 @@ var init_describe2 = __esm({
9432
9445
  var agents_default;
9433
9446
  var init_agents = __esm({
9434
9447
  "docs/agents.md"() {
9435
- agents_default = '# Coding agents\n\nThe CLI is built to be driven by a coding agent: Claude Code, Codex, Cursor, Gemini CLI or anything else that runs shell commands. An agent learns it from the Agent Skill, `kenso --help` and `kenso describe`, and never needs these web pages.\n\n## Set up an agent in three steps\n\n1. Install the CLI where the agent runs: `npm i -g kenso` (Node 22 or newer).\n2. Give it a key. Either set `KENSO_API_KEY` (and `KENSO_WORKSPACE_ID` when the key reaches several workspaces) in the agent\'s environment, or log in once on that machine:\n\n```bash\necho $KEY | kenso auth login --with-key\nkenso workspace use 1234\n```\n\n3. Install the Agent Skill, so the agent knows the rules before its first command:\n\n```bash\nkenso skill install\n```\n\n## The Agent Skill\n\n`kenso skill install` writes the skill `kenso-cli` (one `SKILL.md`) where each agent looks for skills:\n\n| Agent | `--client` | Folder |\n|---|---|---|\n| Claude Code | `claude-code` | `~/.claude/skills/kenso-cli/` |\n| Codex | `codex` | `~/.agents/skills/kenso-cli/` |\n| Cursor | `cursor` | `~/.cursor/skills/kenso-cli/` |\n\nWithout `--client`, it installs for the agent that runs it when that is Claude Code, Codex or Cursor (detected from the environment, see below), else for all three. It prints what it wrote:\n\n```json\n{"installed":[{"client":"claude-code","path":"/home/you/.claude/skills/kenso-cli/SKILL.md","status":"created"}]}\n```\n\n```bash\nkenso skill install --client claude-code\nkenso skill install --client all\nkenso skill install --dir .claude/skills/kenso-cli\nkenso skill install --force\n```\n\n- `--dir` writes the skill into that folder instead, for example a project\'s own skills folder. Name the folder `kenso-cli`, as agents expect the folder to match the skill name.\n- A different `SKILL.md` already in a target folder is replaced only with `--force`; without it the command exits 7, writes nothing, and its `next` adds `--force`. The same file is left alone (`"status":"unchanged"`).\n- `--dry-run` writes nothing and exits 0. It prints each target\'s status (`replaced` marks a different file there) and a `next` that applies it, with `--force` when a file would be replaced.\n- Cursor also reads `~/.claude/skills/` and `~/.agents/skills/`, so after a default install for all three it may list `kenso-cli` more than once. To avoid that, install with `--client cursor` only, or remove the copies you do not need.\n\nThe skill also ships in the Kenso plugin for Claude Code (`Ken-Technology/ken-ai-plugin`), so `npx skills add Ken-Technology/ken-ai-plugin` installs it along with the MCP skills. To read it without installing, open `skill/kenso-cli/SKILL.md` in the package.\n\n## Rules the skill teaches\n\n- **Data is on stdout, as JSON when piped.** Progress, warnings and errors are on stderr. Parse stdout; read stderr when the exit code is not 0.\n- **Plan, then apply.** Run a write with `--dry-run`, read the plan, then run the exact command in its `next` (the same command with `--yes`).\n- **Pass files, not rows.** Never paste a CSV into the conversation. Write the file, pass its path with `--file`, and read the summary.\n- **Read failures only.** If the summary\'s `failed`, `notFound` and `invalid` are 0, the work is done. Otherwise read just the problem rows from `results.csv` or `invalid.csv`.\n- **Follow `next`.** Every error with a known fix carries the exact command to run.\n- **Branch on the exit code:** 0 ok, 2 fix the command, 3 fix the key, 5 some rows failed, 6 wait or raise a limit, 130 resume.\n- **Look things up, do not guess.** `kenso describe <resource> <action>` prints any command\'s flags and body schema as JSON.\n\n## The token-saving pattern\n\nAdding 5,000 leads through the MCP sends every lead through the model, hundreds of thousands of tokens. Through the CLI it is two commands and two short JSON objects:\n\n```bash\nkenso contacts import --campaign 812 --file leads.csv --dry-run\nkenso contacts import --campaign 812 --file leads.csv --yes\n```\n\nThen, only if the summary shows failures:\n\n```bash\ngrep -E \'^[0-9]+,(failed|not_found|invalid),\' ~/.kenso/runs/r_tdi5cdnx/results.csv | head -50\n```\n\n## Environment variables\n\n| Variable | What it does |\n|---|---|\n| `KENSO_API_KEY` | The API key. Wins over any saved profile |\n| `KENSO_WORKSPACE_ID` | The workspace for every command. `--workspace` wins over it |\n| `KENSO_PROFILE` | The saved profile to use. `--profile` wins over it |\n| `KENSO_API_URL` | The API, default `https://api.ken.so` |\n| `KENSO_CONFIG_DIR` | Where the config file and cache live, default `~/.kenso` |\n| `KENSO_RUNS_DIR` | Where run folders live, default `<config dir>/runs` |\n| `KENSO_MCP_URL` | The MCP for `kenso mcp`, default `https://mcp.ken.so/mcp` |\n\n## Cloud sandboxes\n\nCloud agents (Claude Code on the web, Codex cloud, Cursor background agents and similar) often run with a network allowlist. Allow:\n\n- `api.ken.so`: every command\n- `mcp.ken.so`: only `kenso mcp tools` and `kenso mcp call`\n- `registry.npmjs.org`: to install the CLI with npm\n\nStore the key as the platform\'s secret and expose it as `KENSO_API_KEY`. Run `kenso doctor` as the first command: it checks Node, the config, that the API is reachable and that the key works, and exits 0 when all four pass.\n\n```bash\nkenso doctor\n```\n\nPoint the run folders at the workspace if the home folder does not persist between sessions: `KENSO_RUNS_DIR=./.kenso-runs`. Keep that folder out of git.\n\n## How Kenso knows an agent ran a command\n\nThe CLI checks these environment variables, in this order: `CLAUDECODE` and `CLAUDE_CODE_ENTRYPOINT` (Claude Code), `CODEX_SANDBOX` and `CODEX_THREAD_ID` (Codex), `CURSOR_AGENT` (Cursor), `GEMINI_CLI` (Gemini CLI), `OPENCODE` (OpenCode), then `AI_AGENT`, a free-form name any other agent can set. When one is set:\n\n- every request carries `X-Ken-Client: Kenso CLI via <agent>`, and writes carry `X-Ken-Actor: agent`, so Kenso shows the change as made by you through that agent;\n- help ends with a short "For agents" block;\n- browser login never opens a browser itself: it prints the link for you to open.\n\n`kenso doctor` shows the agent it detected.\n\n## MCP-only tools\n\nA few Kenso MCP tools have no API route, such as the skills Kenso\'s own agents use. Reach them from the shell with the same key:\n\n```bash\nkenso mcp tools\nkenso mcp call skills --operation list\nkenso mcp call contacts --operation list --args \'{"limit":5}\'\n```\n\n`mcp call` prints the tool\'s structured result, else its text parsed as JSON, else the text. A tool error exits 1 with `mcp_tool_error`.\n';
9448
+ agents_default = '# Coding agents\n\nThe CLI is built to be driven by a coding agent: Claude Code, Codex, Cursor, Gemini CLI or anything else that runs shell commands. An agent learns it from the Agent Skill, `kenso --help` and `kenso describe`, and never needs these web pages.\n\n## Set up an agent in three steps\n\n1. Install the CLI where the agent runs: `npm i -g kenso` (Node 22 or newer).\n2. Give it a key. Either set `KENSO_API_KEY` (and `KENSO_WORKSPACE_ID` when the key reaches several workspaces) in the agent\'s environment, or log in once on that machine:\n\n```bash\necho $KEY | kenso auth login --with-key\nkenso workspace use 1234\n```\n\n3. Install the Agent Skill, so the agent knows the rules before its first command:\n\n```bash\nkenso skill install\n```\n\n## The Agent Skill\n\n`kenso skill install` writes the skill `kenso-cli` (one `SKILL.md`) where each agent looks for skills:\n\n| Agent | `--client` | Folder |\n|---|---|---|\n| Claude Code | `claude-code` | `~/.claude/skills/kenso-cli/` |\n| Codex | `codex` | `~/.agents/skills/kenso-cli/` |\n| Cursor | `cursor` | `~/.cursor/skills/kenso-cli/` |\n\nWithout `--client`, it installs for the agent that runs it when that is Claude Code, Codex or Cursor (detected from the environment, see below), else for all three. It prints what it wrote:\n\n```json\n{"installed":[{"client":"claude-code","path":"/home/you/.claude/skills/kenso-cli/SKILL.md","status":"created"}]}\n```\n\n```bash\nkenso skill install --client claude-code\nkenso skill install --client all\nkenso skill install --dir .claude/skills/kenso-cli\nkenso skill install --force\n```\n\n- `--dir` writes the skill into that folder instead, for example a project\'s own skills folder. Name the folder `kenso-cli`, as agents expect the folder to match the skill name.\n- A different `SKILL.md` already in a target folder is replaced only with `--force`; without it the command exits 7, writes nothing, and its `next` adds `--force`. The same file is left alone (`"status":"unchanged"`).\n- `--dry-run` writes nothing and exits 0. It prints each target\'s status (`replaced` marks a different file there) and a `next` that applies it, with `--force` when a file would be replaced.\n- Cursor also reads `~/.claude/skills/` and `~/.agents/skills/`, so after a default install for all three it may list `kenso-cli` more than once. To avoid that, install with `--client cursor` only, or remove the copies you do not need.\n\nThe skill also ships in the Kenso plugin for Claude Code (`Ken-Technology/ken-ai-plugin`), so `npx skills add Ken-Technology/ken-ai-plugin` installs it along with the MCP skills. To read it without installing, open `skill/kenso-cli/SKILL.md` in the package.\n\n## Rules the skill teaches\n\n- **Data is on stdout, as JSON when piped.** Progress, warnings and errors are on stderr. Parse stdout; read stderr when the exit code is not 0.\n- **Plan, then apply.** Run a write with `--dry-run`, read the plan, then run the exact command in its `next` (the same command with `--yes`).\n- **Pass files, not rows.** Never paste a CSV into the conversation. Write the file, pass its path with `--file`, and read the summary.\n- **Read failures only.** If the summary\'s `failed`, `notFound` and `invalid` are 0, the work is done. Otherwise read just the problem rows from `results.csv` or `invalid.csv`.\n- **Follow `next`.** Every error with a known fix carries the exact command to run.\n- **Branch on the exit code:** 0 ok, 2 fix the command, 3 fix the key, 5 some rows failed, 6 wait or raise a limit, 130 resume.\n- **Look things up, do not guess.** `kenso describe <resource> <action>` prints any command\'s flags and body schema as JSON.\n\n## The token-saving pattern\n\nAdding 5,000 leads through the MCP sends every lead through the model, hundreds of thousands of tokens. Through the CLI it is two commands and two short JSON objects:\n\n```bash\nkenso contacts import --campaign 812 --file leads.csv --dry-run\nkenso contacts import --campaign 812 --file leads.csv --yes\n```\n\nThen, only if the summary shows failures:\n\n```bash\ngrep -E \'^[0-9]+,(failed|not_found|invalid),\' ~/.kenso/runs/r_tdi5cdnx/results.csv | head -50\n```\n\n## Environment variables\n\n| Variable | What it does |\n|---|---|\n| `KENSO_API_KEY` | The API key. Wins over any saved profile |\n| `KENSO_WORKSPACE_ID` | The workspace for every command. `--workspace` wins over it |\n| `KENSO_PROFILE` | The saved profile to use. `--profile` wins over it |\n| `KENSO_API_URL` | The API, default `https://api.ken.so` |\n| `KENSO_CONFIG_DIR` | Where the config file and cache live, default `~/.kenso` |\n| `KENSO_RUNS_DIR` | Where run folders live, default `<config dir>/runs` |\n| `KENSO_MCP_URL` | The MCP for `kenso mcp`, default `https://mcp.ken.so/mcp` |\n\n## Cloud sandboxes\n\nCloud agents (Claude Code on the web, Codex cloud, Cursor background agents and similar) often run with a network allowlist. Allow:\n\n- `api.ken.so`: every command\n- `mcp.ken.so`: only `kenso mcp tools` and `kenso mcp call`\n- `registry.npmjs.org`: to install the CLI with npm\n\nStore the key as the platform\'s secret and expose it as `KENSO_API_KEY`. Run `kenso doctor` as the first command: it checks Node, the config, that the API is reachable and that the key works, and exits 0 when all four pass.\n\n```bash\nkenso doctor\n```\n\nPoint the run folders at the workspace if the home folder does not persist between sessions: `KENSO_RUNS_DIR=./.kenso-runs`. Keep that folder out of git.\n\n## How Kenso knows an agent ran a command\n\nThe CLI checks these environment variables, in this order: `CLAUDECODE` and `CLAUDE_CODE_ENTRYPOINT` (Claude Code), `CODEX_SANDBOX` and `CODEX_THREAD_ID` (Codex), `CURSOR_AGENT` (Cursor), `GEMINI_CLI` (Gemini CLI), `OPENCODE` (OpenCode), then `AI_AGENT`, a free-form name any other agent can set. When one is set:\n\n- every request carries `X-Ken-Client: Kenso CLI via <agent>`, and writes carry `X-Ken-Actor: agent`, so Kenso shows the change as made by you through that agent;\n- help ends with a short "For agents" block;\n- browser login never opens a browser itself: it prints the link for you to open.\n\n`kenso doctor` shows the agent it detected.\n\n## MCP-only tools\n\nA few Kenso MCP tools have no API route, such as the skills Kenso\'s own agents use. Reach them from the shell with the same key:\n\n```bash\nkenso mcp tools\nkenso mcp call list_skills\nkenso mcp call load_skill --args \'{"name":"create-campaign"}\'\nkenso mcp call contacts --operation list --args \'{"limit":5}\'\n```\n\n`mcp call` prints the tool\'s structured result, else its text parsed as JSON, else the text. A tool error exits 1 with `mcp_tool_error`.\n';
9436
9449
  }
9437
9450
  });
9438
9451
 
@@ -9464,7 +9477,7 @@ var init_crm = __esm({
9464
9477
  var leads_default;
9465
9478
  var init_leads = __esm({
9466
9479
  "docs/leads.md"() {
9467
- 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 `-`.\n\n```bash\nkenso contacts remove --ids ids.txt\nkenso contacts remove --ids ids.txt --yes\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.** Search by filter, check 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';
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';
9468
9481
  }
9469
9482
  });
9470
9483
 
@@ -9496,7 +9509,7 @@ var init_studio = __esm({
9496
9509
  var SKILL_default;
9497
9510
  var init_SKILL = __esm({
9498
9511
  "skill/kenso-cli/SKILL.md"() {
9499
- SKILL_default = '---\nname: kenso-cli\ndescription: Use the Kenso CLI (kenso) for bulk lead, CRM and Studio work in a Kenso workspace from files - import, update, remove, pause or tag leads in campaigns, import accounts and people, fill Studio templates, or call any Kenso API operation from the shell. Prefer it over the Kenso MCP for anything over about 50 records.\n---\n\n# Kenso CLI\n\n`kenso` does bulk work in Kenso from files and prints JSON. Use it instead of the Kenso MCP when the work starts from a file or touches more than about 50 records: the rows stay on disk, and you read a short summary.\n\n## Install and auth\n\n```bash\nnpm i -g kenso\nkenso doctor\n```\n\nNeeds Node 22+. Auth, in order of precedence:\n\n- `KENSO_API_KEY` in the environment (plus `KENSO_WORKSPACE_ID` when the key reaches several workspaces). Nothing else to do.\n- Or a saved key. The key is never a flag value; pipe it in. The person creates it in app.ken.so at Settings > Integrations > API keys.\n\n```bash\necho $KEY | kenso auth login --with-key\nkenso workspace list --q acme\nkenso workspace use 1234\nkenso auth status\n```\n\nNever print, echo to the conversation, or write the key into a file in the repo. `kenso auth login` alone is browser login: it may answer `browser_login_unavailable`; then use `--with-key`. If it works, it prints a link: ask the person to open it.\n\n## Golden rules\n\n1. **stdout is data, stderr is everything else.** Piped output is JSON: one object, NDJSON for lists, one JSON summary for a batch. Errors are one JSON line on stderr: `{"error":{"code","message","field","hint","next","httpStatus","requestId"}}`.\n2. **Plan, then apply.** A batch command (import, update, remove, tag, `--each`) only plans without `--yes`: run it with `--dry-run`, read the plan, then run the exact command in its `next` (the same command with `--yes`). Never pass both. A single `kenso <resource> <action>` write and `kenso api` send at once: run it with `--dry-run` to see the request, then without it.\n3. **Pass files, not rows.** Never paste a CSV or a list of records into the conversation or a command line. Write a file and pass `--file path` (or `--ids path`).\n4. **Read the summary, then failures only.** If `failed`, `notFound` and `invalid` are 0, you are done. Otherwise read only the problem rows: `grep -E \'^[0-9]+,(failed|not_found|invalid),\' <results> | head -50`, or `invalidRows`. Do not read the whole results file.\n5. **Follow `next`.** An error with a known fix carries the exact command to run in `next`, and what to change in `hint`.\n6. **Branch on the exit code:**\n\n| Exit | Meaning | Do |\n|---|---|---|\n| 0 | OK (skipped rows are not failures) | Report the summary |\n| 1 | Unexpected error | Rerun with `--verbose`, report |\n| 2 | Usage or validation error | Fix the flags or the file (see `field`, `hint`) |\n| 3 | No key, bad key, or 401/403 | Ask for a key or a permission; do not retry |\n| 4 | Not found | Check the id |\n| 5 | Some rows failed, not found or invalid | Read the failed rows only, fix, resend those |\n| 6 | Rate limited, or a lead limit (`limit_reached`) stopped the run | Wait, or ask the person to raise the limit, then rerun |\n| 7 | Conflict, or another process holds the run | Read `message`; run a `next` with `--force` only after the person agrees |\n| 8 | Server error after retries | Wait a minute, rerun the same command |\n| 130 | Interrupted | Run the `next`: `kenso runs resume <runId>` |\n\n## Leads: import\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- CSV with a header row, or JSONL. Known columns: `email`, `firstName`, `lastName`, `title`, `linkedInUrl`, `companyName`, `companyDomain` (common header spellings are matched). Every other column becomes a custom variable in snake_case (`Pain Point` fills `{{pain_point}}`).\n- Rename or drop columns: `--map \'{"Pain": "customVariables.pain_point", "Notes": null}\'`.\n- Blank cells are never sent; the literal `null` clears a custom variable.\n- The dry run checks every row locally (`invalid_email`, `missing_first_name`, `duplicate_in_file` ...), shows the column `mapping` and the credit estimate. Check the mapping before you apply.\n- Row statuses: `created`, `updated`, `skipped` (with `reason`: `duplicate`, `blocklisted`, `already_contacted` ...), `failed`, `invalid`. `notSendable` counts saved leads that cannot be emailed yet.\n- Up to 100,000 rows without per-row results: add `--server-import`.\n\n## Leads: update and cleanup\n\n```bash\nkenso contacts update --file edits.csv --match id --dry-run\nkenso contacts update --file edits.csv --match email --campaign 812 --yes\nkenso contacts remove --ids ids.txt --dry-run\nkenso contacts remove --where \'{"campaignIds": [812], "q": "acme"}\' --yes\nkenso contacts restore --run r_ab3dxk2q --yes\nkenso contacts pause --ids ids.txt --yes\nkenso contacts resume --ids ids.txt --yes\nkenso contacts tag --tag 31 --ids ids.txt --yes\nkenso contacts export --campaign 812 --out contacts.csv\n```\n\n- `update`: match by an `id`/`contactId` column, or by email within `--campaign`. A missing column or blank cell leaves a field unchanged; `null` clears it.\n- `--ids` takes one id per line, or a CSV/JSONL with an `id` column, or `-` for stdin. Get ids with `kenso contacts list --campaign-id 812 --all -o ids > ids.txt`.\n- Removing more than 1,000 needs `--confirm <count>` (the dry run\'s `next` has it). The remove summary has `undo`: keep it.\n\n## Runs and resume\n\nEvery batch run has an id (`r_...`) and a folder with `results.csv` (one line per input row: `row,status,id,reason,error`), `invalid.csv`, `plan.json` and `run.json`. The summary prints their absolute paths.\n\n- Rerunning the same command on the same file continues an unfinished run (`"resumed":true`) or, when every row already succeeded, answers `"changed":0` and sends no writes. Writes match existing records, so a resend never duplicates.\n- `kenso runs list`, `kenso runs show <runId>`, `kenso runs resume <runId>`.\n- `--concurrency` (1-16, default 4) works on `contacts update`, `remove`, `restore`, `pause`, `resume`, `tag`, `untag`, `people import`, `accounts import` and `--each`. `contacts import` sends one chunk at a time per campaign and has no such flag. 429s and transient 5xx are retried for you. Do not add your own retry loop.\n\n## CRM and Studio (beta)\n\nNeed the `accounts` or `studio` flag. Same plan-then-apply rules.\n\n```bash\nkenso accounts import --file accounts.csv --dry-run\nkenso people import --file people.csv --dry-run\nkenso studio pull 12 --dir ./tpl\nkenso studio push 12 --dir ./tpl\nkenso studio fill 12 --accounts ids.txt\n```\n\n`accounts import` charges research per created account: show the person the estimate first. `studio fill` charges per page and refuses to rerun a fill of the same file with the same flags (exit 7), but the same ids in another file are charged again. Read `kenso docs crm` and `kenso docs studio` before using them.\n\n## Workflows in plain steps (beta)\n\nNeeds the `workflows` and `workflows_plain` flags. Your own model writes the workflow from the person\'s words, in plain steps or as custom JSON/code; Kenso only runs it, so writing costs no Kenso AI credits.\n\n```bash\nkenso workflows brief > brief.json\nkenso workflows brief --types ai.qualify@1,logic.filter@1 --include stepTypes\nkenso workflows create --data @create.json --dry-run\nkenso workflows validate --workflow-id 42\nkenso workflow-builder test --workflow-id 42 --data @test.json\nkenso workflows publish --workflow-id 42\n```\n\n- Start with `brief`: `authoring` (how plain steps become engine steps), `stepTypes`, `triggers`, `examples` and `workspace` (campaigns, Slack, secret names). `--workflow-id` returns the current definition to change; save it with `kenso workflows update --workflow-id 42 --data @update.json` and its `baseVersion`.\n- `create.json` is `{"definition": {...}}`, the definition with its `plain` block. Fix every issue `validate` names, test on one record (`{"record": {"type": "person", "id": 123}}`), then publish.\n- Never write a secret\'s value into a definition or the conversation: reference `{{secrets.NAME}}` and ask the person to add it in app.ken.so at Settings > Integrations > Secrets.\n\n## Everything else\n\nEvery API operation is a command: `kenso <resource> <action>` (`kenso campaigns list`, `kenso tags create --name VIP`). A write like that runs at once, without `--yes`: check it with `--dry-run` first. Look commands up instead of guessing flags:\n\n```bash\nkenso describe\nkenso describe campaigns\nkenso describe campaigns update\nkenso campaigns update --help\n```\n\n- Lists print one page; `--all` streams every page, `--limit` and `--cursor` page by hand. `--fields a,b` and `--query \'<JMESPath>\'` shape output without `jq`.\n- Many records: `--each file.csv` runs a generated command once per row (row keys are flag or API names), with results and resume. It needs `--yes` or `--dry-run` unless it is a GET.\n- Beta commands are hidden from help until the workspace has the flag; `--all-commands` shows them.\n- Raw route: `kenso api GET /v1/me`, `kenso api POST /v1/tags --data @tag.json`. Writes through `kenso api` run without `--yes`, so be sure first.\n- MCP-only tools (such as skills): `kenso mcp tools`, `kenso mcp call skills --operation list`.\n- Offline docs: `kenso docs`, `kenso docs leads`.\n';
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';
9500
9513
  }
9501
9514
  });
9502
9515
 
@@ -9993,14 +10006,14 @@ var init_call = __esm({
9993
10006
  command13 = {
9994
10007
  resource: "mcp",
9995
10008
  action: "call",
9996
- summary: "Call a hosted Kenso MCP tool",
9997
- 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 skills. A tool error exits 1 with the tool's message. Tools are facades: pass the facade's operation with --operation.",
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.",
9998
10011
  flags: {
9999
10012
  operation: { type: "string", description: "The facade operation, for example list" },
10000
10013
  args: { type: "string", description: "Tool arguments as a JSON object: inline, @file or - for stdin" }
10001
10014
  },
10002
10015
  positionals: [{ name: "tool", required: true, description: "The tool name (see kenso mcp tools)" }],
10003
- examples: [`kenso mcp call contacts --operation list --args '{"limit":5}'`, "kenso mcp call skills --operation list"],
10016
+ examples: [`kenso mcp call contacts --operation list --args '{"limit":5}'`, "kenso mcp call list_skills", `kenso mcp call load_skill --args '{"name":"create-campaign"}'`],
10004
10017
  operationIds: [],
10005
10018
  async run(ctx, { flags, positionals }) {
10006
10019
  const tool = positionals[0];
@@ -11142,7 +11155,7 @@ var init_preview = __esm({
11142
11155
  command23 = {
11143
11156
  resource: "studio",
11144
11157
  action: "preview",
11145
- summary: "Render a template version for up to 3 accounts and print the preview links",
11158
+ summary: "Render a saved version for up to 3 accounts; for local edits, studio push them to the draft first",
11146
11159
  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).",
11147
11160
  flags: {
11148
11161
  dir: { type: "string", description: "A template folder to compare with the version being rendered" },
@@ -11688,13 +11701,28 @@ async function visibleSpecs(specs, opts, getFeatures, hidden) {
11688
11701
  visible.push(spec);
11689
11702
  } else {
11690
11703
  hidden.count++;
11704
+ hidden.names.push(`${spec.resource} ${spec.action}`);
11691
11705
  hidden.gates.add(describeGates(gates));
11692
11706
  }
11693
11707
  }
11694
11708
  return visible;
11695
11709
  }
11696
- function columns(rows, indent = " ") {
11697
- const width = Math.max(0, ...rows.map(([left]) => left.length));
11710
+ function wrap(text2, indent) {
11711
+ const out = [];
11712
+ let line = "";
11713
+ for (const word of text2.split(" ")) {
11714
+ if (line && indent.length + line.length + 1 + word.length > 110) {
11715
+ out.push(indent + line);
11716
+ line = word;
11717
+ } else {
11718
+ line = line ? `${line} ${word}` : word;
11719
+ }
11720
+ }
11721
+ if (line) out.push(indent + line);
11722
+ return out.join("\n");
11723
+ }
11724
+ function columns(rows, indent = " ", maxWidth = 28) {
11725
+ const width = Math.min(maxWidth, Math.max(0, ...rows.map(([left]) => left.length)));
11698
11726
  return rows.map(([left, right]) => `${indent}${left.padEnd(width)} ${right}`.trimEnd()).join("\n");
11699
11727
  }
11700
11728
  function withAgentBlock(text2, agent) {
@@ -11730,10 +11758,18 @@ async function renderTopHelp(opts) {
11730
11758
  " kenso <resource> <action> [flags]",
11731
11759
  " kenso <resource> <action> --help",
11732
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.",
11768
+ "",
11733
11769
  "Commands:"
11734
11770
  ];
11735
11771
  const getFeatures = lazyFeatures(opts);
11736
- const hiddenSpecs = { count: 0, gates: /* @__PURE__ */ new Set() };
11772
+ const hiddenSpecs = { count: 0, gates: /* @__PURE__ */ new Set(), names: [] };
11737
11773
  const rows = [];
11738
11774
  for (const resource of Object.keys(handWritten).sort()) {
11739
11775
  for (const spec of await visibleSpecs(await loadArea(resource), opts, getFeatures, hiddenSpecs)) {
@@ -11743,6 +11779,9 @@ async function renderTopHelp(opts) {
11743
11779
  }
11744
11780
  }
11745
11781
  lines.push(columns(rows));
11782
+ if (hiddenSpecs.names.length > 0) {
11783
+ lines.push(` Beta, hidden for this workspace (they still run; the server decides): ${hiddenSpecs.names.sort().join(", ")}.`);
11784
+ }
11746
11785
  const generated = await listGenerated({ features: opts.allCommands ? [] : getFeatures, env: opts.env });
11747
11786
  const visible = generated.filter((g) => opts.allCommands || !g.beta);
11748
11787
  if (visible.length > 0) {
@@ -11752,18 +11791,31 @@ async function renderTopHelp(opts) {
11752
11791
  actions15.push(`${g.action}${g.beta ? " (beta)" : ""}`);
11753
11792
  byResource.set(g.resource, actions15);
11754
11793
  }
11755
- const generatedRows = [...byResource.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([resource, actions15]) => [resource, actions15.sort().join(", ")]);
11756
- lines.push("", "API resources (kenso <resource> <action>):", columns(generatedRows));
11794
+ const sorted = [...byResource.entries()].sort(([a], [b]) => a.localeCompare(b));
11795
+ if (opts.allCommands) {
11796
+ const generatedRows = sorted.map(([resource, actions15]) => [resource, actions15.sort().join(", ")]);
11797
+ lines.push("", "API resources (kenso <resource> <action>):", columns(generatedRows));
11798
+ } else {
11799
+ const names = new Set(sorted.map(([resource]) => resource));
11800
+ const common = COMMON_RESOURCES.filter((r) => names.has(r)).join(", ");
11801
+ lines.push(
11802
+ "",
11803
+ `API resources (${names.size}), one per /v1 route group and named like it (/v1/contact-exports: kenso contact-exports).`,
11804
+ wrap(`Common: ${common}.`, " "),
11805
+ " kenso <resource> --help lists its actions; kenso --help --all-commands lists every resource."
11806
+ );
11807
+ }
11757
11808
  }
11758
11809
  const hidden = generated.length - visible.length + hiddenSpecs.count;
11759
11810
  if (hidden > 0) lines.push("", `${hidden} beta command${hidden === 1 ? " is" : "s are"} hidden; --all-commands shows them.`);
11760
- lines.push("", "Global flags:", columns(GLOBAL_FLAG_HELP), "", EXIT_CODES);
11811
+ if (opts.allCommands) lines.push("", "Global flags:", columns(GLOBAL_FLAG_HELP), "", EXIT_CODES);
11812
+ else lines.push("", GLOBAL_FLAGS_LINE, EXIT_CODES);
11761
11813
  return withAgentBlock(lines.join("\n"), opts.agent);
11762
11814
  }
11763
11815
  async function renderResourceHelp(resource, opts) {
11764
11816
  const getFeatures = lazyFeatures(opts);
11765
11817
  const all = await loadArea(resource);
11766
- const hidden = { count: 0, gates: /* @__PURE__ */ new Set() };
11818
+ const hidden = { count: 0, gates: /* @__PURE__ */ new Set(), names: [] };
11767
11819
  const specs = await visibleSpecs(all, opts, getFeatures, hidden);
11768
11820
  const handWrittenActions = new Set(all.map((s) => s.action));
11769
11821
  const listed = (await listGenerated({ features: opts.allCommands ? [] : getFeatures, env: opts.env })).filter(
@@ -11787,10 +11839,18 @@ async function renderResourceHelp(resource, opts) {
11787
11839
  });
11788
11840
  }
11789
11841
  const rows = [
11790
- ...specs.map((s) => [usageLine2(s).slice("kenso ".length), `${s.summary}${s.beta ? " (beta)" : ""}`]),
11791
- ...generated.map((g) => [`${resource} ${g.action}`, `${g.summary}${g.beta ? " (beta)" : ""}`])
11842
+ ...specs.map((s) => [usageLine2(s).slice(`kenso ${resource} `.length).replace(/ ?\[flags\]$/, ""), `${s.summary}${s.beta ? " (beta)" : ""}`]),
11843
+ ...generated.map((g) => [g.action, `${g.summary}${g.beta ? " (beta)" : ""}`])
11792
11844
  ].sort(([a], [b]) => a.localeCompare(b));
11793
- const lines = [`Usage: kenso ${resource} <action> [flags]`, "", "Actions:", columns(rows), "", `Run kenso ${resource} <action> --help for an action's flags.`];
11845
+ const lines = [`Usage: kenso ${resource} <action> [flags]`, "", "Actions:", columns(rows)];
11846
+ const hiddenActions = [
11847
+ ...all.filter((s) => !specs.includes(s)).map((s) => s.action),
11848
+ ...listed.filter((g) => !generated.includes(g)).map((g) => g.action)
11849
+ ].sort();
11850
+ if (hiddenActions.length > 0) {
11851
+ lines.push("", `Beta, hidden for this workspace (they still run; the server decides): ${hiddenActions.join(", ")}.`);
11852
+ }
11853
+ lines.push("", `Run kenso ${resource} <action> --help for an action's flags.`);
11794
11854
  return withAgentBlock(lines.join("\n"), opts.agent);
11795
11855
  }
11796
11856
  function renderCommandHelp(spec, opts) {
@@ -11799,16 +11859,18 @@ function renderCommandHelp(spec, opts) {
11799
11859
  if (spec.positionals && spec.positionals.length > 0) {
11800
11860
  lines.push("", "Arguments:", columns(spec.positionals.map((p) => [`<${p.name}>`, `${p.description}${p.required ? "" : " (optional)"}`])));
11801
11861
  }
11802
- const flagRows = Object.entries(spec.flags).sort(([a], [b]) => a.localeCompare(b)).map(([name, flag]) => {
11862
+ 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]) => {
11803
11864
  const notes = [flag.required ? "required" : "", flag.default !== void 0 ? `default ${String(flag.default)}` : ""].filter(Boolean);
11804
- return [flagLabel(name, flag), `${flag.description}${notes.length ? ` (${notes.join(", ")})` : ""}`];
11865
+ const label = paired(name) ? flagLabel(name, flag).replace(`--${name}`, `--[no-]${name}`) : flagLabel(name, flag);
11866
+ return [label, `${flag.description}${notes.length ? ` (${notes.join(", ")})` : ""}`];
11805
11867
  });
11806
11868
  if (flagRows.length > 0) lines.push("", "Flags:", columns(flagRows));
11807
- lines.push("", "Global flags:", columns(GLOBAL_FLAG_HELP));
11869
+ lines.push("", GLOBAL_FLAGS_LINE);
11808
11870
  if (spec.examples.length > 0) lines.push("", "Examples:", ...spec.examples.map((e) => ` ${e}`));
11809
11871
  return withAgentBlock(lines.join("\n"), opts.agent);
11810
11872
  }
11811
- var GLOBAL_FLAG_HELP, EXIT_CODES, FOR_AGENTS;
11873
+ var GLOBAL_FLAG_HELP, GLOBAL_FLAGS_LINE, COMMON_RESOURCES, EXIT_CODES, FOR_AGENTS;
11812
11874
  var init_help = __esm({
11813
11875
  "src/commands/help.ts"() {
11814
11876
  "use strict";
@@ -11832,6 +11894,26 @@ var init_help = __esm({
11832
11894
  ["-h, --help", "Show help"],
11833
11895
  ["--version", "Print the version"]
11834
11896
  ];
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).";
11898
+ COMMON_RESOURCES = [
11899
+ "accounts",
11900
+ "analytics",
11901
+ "blocklist-entries",
11902
+ "campaigns",
11903
+ "contact-exports",
11904
+ "contact-imports",
11905
+ "contacts",
11906
+ "domains",
11907
+ "inboxes",
11908
+ "lists",
11909
+ "members",
11910
+ "prompts",
11911
+ "segments",
11912
+ "tags",
11913
+ "threads",
11914
+ "webhook-endpoints",
11915
+ "workspaces"
11916
+ ];
11835
11917
  EXIT_CODES = "Exit codes: 0 ok, 1 error, 2 usage, 3 auth, 4 not found, 5 some rows failed, 6 rate limited or limit reached, 7 conflict, 8 server error, 130 interrupted.";
11836
11918
  FOR_AGENTS = [
11837
11919
  "For agents:",
@@ -11950,11 +12032,13 @@ async function unknownCommand(tokens, resource) {
11950
12032
  const all = await knownCommands();
11951
12033
  const scoped = tokens.length > 1 && all.has(resource) ? [...all].filter((c) => c.startsWith(`${resource} `)) : [...all];
11952
12034
  const best = closest(typed, scoped);
12035
+ const related = all.has(resource) ? [] : [...all].filter((c) => !c.includes(" ") && c !== best && (c.includes(resource) || c.length >= 4 && resource.includes(c))).sort().slice(0, 6);
12036
+ const hints = [best ? `did you mean "kenso ${best}"?` : "", related.length ? `Resources named like it: ${related.join(", ")}.` : ""];
11953
12037
  return new KensoError({
11954
12038
  code: "unknown_command",
11955
12039
  message: `Unknown command "kenso ${tokens.join(" ")}".`,
11956
12040
  exitCode: ExitCode.Usage,
11957
- hint: best ? `did you mean "kenso ${best}"?` : "Run kenso --help to list the commands.",
12041
+ hint: hints.filter(Boolean).join(" ") || "Run kenso --help to list the commands.",
11958
12042
  next: "kenso --help"
11959
12043
  });
11960
12044
  }