kenso 0.1.12 → 0.1.14
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 +125 -41
- package/dist/operations.json +1 -1
- package/docs/agents.md +2 -1
- package/docs/leads.md +3 -2
- package/docs/reference.md +4459 -27062
- package/package.json +2 -2
- package/skill/kenso-cli/SKILL.md +18 -133
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.
|
|
2400
|
+
VERSION = true ? "0.1.14" : "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
|
|
6313
|
-
|
|
6314
|
-
|
|
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
|
|
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
|
|
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
|
|
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" },
|
|
@@ -8382,10 +8395,10 @@ async function readCampaign(ctx, campaignId) {
|
|
|
8382
8395
|
const channel = isRecord4(campaign) && typeof campaign.channel === "string" ? campaign.channel.toLowerCase() : "";
|
|
8383
8396
|
return { linkedInOnly: LINKEDIN_ONLY_CHANNELS.has(channel) ? true : void 0 };
|
|
8384
8397
|
}
|
|
8385
|
-
async function estimate(ctx,
|
|
8398
|
+
async function estimate(ctx, leadCount) {
|
|
8386
8399
|
if (leadCount === 0) return { estimate: null };
|
|
8387
8400
|
try {
|
|
8388
|
-
const res = await ctx.client.request({ method: "POST", path: "/v1/credits/estimates", body: { kind: "leads", leadCount
|
|
8401
|
+
const res = await ctx.client.request({ method: "POST", path: "/v1/credits/estimates", body: { kind: "leads", leadCount } });
|
|
8389
8402
|
const data = unwrap(res.body);
|
|
8390
8403
|
if (!isRecord4(data)) return { estimate: data ?? null };
|
|
8391
8404
|
const credits = (key) => typeof data[key] === "number" ? data[key] / 1e3 : void 0;
|
|
@@ -8489,7 +8502,7 @@ var init_import2 = __esm({
|
|
|
8489
8502
|
...Object.keys(mapping.dropped).length > 0 ? { droppedColumns: mapping.dropped } : {},
|
|
8490
8503
|
...emailLess > 0 ? { emailLessRows: emailLess } : {}
|
|
8491
8504
|
};
|
|
8492
|
-
if (dryRun2) Object.assign(dryRunExtra, await estimate(ctx,
|
|
8505
|
+
if (dryRun2) Object.assign(dryRunExtra, await estimate(ctx, items.length));
|
|
8493
8506
|
const outcome = await runBatch(
|
|
8494
8507
|
{
|
|
8495
8508
|
command: "contacts import",
|
|
@@ -8634,7 +8647,7 @@ var init_update = __esm({
|
|
|
8634
8647
|
command8 = {
|
|
8635
8648
|
resource: "contacts",
|
|
8636
8649
|
action: "update",
|
|
8637
|
-
summary: "Edit leads
|
|
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"}' --
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
11697
|
-
const
|
|
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
|
|
11756
|
-
|
|
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(
|
|
11791
|
-
...generated.map((g) => [
|
|
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)
|
|
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
|
|
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
|
-
|
|
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("",
|
|
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:
|
|
12041
|
+
hint: hints.filter(Boolean).join(" ") || "Run kenso --help to list the commands.",
|
|
11958
12042
|
next: "kenso --help"
|
|
11959
12043
|
});
|
|
11960
12044
|
}
|