/** * `dench leads find`, `dench jobs find`, and workspace artifact * list/show/import. Find writes a durable table artifact; import is the * same path as the chat card. */ import { readFile } from "node:fs/promises"; import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { assertNoUnknownFlags as assertNoUnknownFlagsRaw, CliArgError, getFlag, hasFlag, shift as shiftRaw, } from "./lib/cli-args"; import { type CompanyRelatedKind, companyRelatedRecords, EnrichmentGatewayError, type EnrichmentGatewayOptions, searchCompanies, searchJobs, searchPeople, } from "./lib/enrichment-gateway"; import { FIND_FILTER_OPTION_CATALOG } from "./lib/find-filter-options.generated"; import { parseProspectAssessments } from "./lib/prospect-assessment"; class FindCliError extends Error { constructor(message: string) { super(message); this.name = "FindCliError"; } } type FindCliContext = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; sessionToken?: string; enrichmentGateway?: Pick; }; const api = { saveFind: makeFunctionReference<"action">( "functions/workspaceArtifacts:saveFind", ), updateFind: makeFunctionReference<"action">( "functions/workspaceArtifacts:updateFind", ), rename: makeFunctionReference<"mutation">( "functions/workspaceArtifacts:rename", ), remove: makeFunctionReference<"mutation">( "functions/workspaceArtifacts:remove", ), list: makeFunctionReference<"query">("functions/workspaceArtifacts:list"), listSaved: makeFunctionReference<"query">( "functions/workspaceArtifacts:listSaved", ), listRows: makeFunctionReference<"action">( "functions/workspaceArtifacts:listRows", ), enrichSelected: makeFunctionReference<"action">( "functions/workspaceArtifacts:enrichSelected", ), get: makeFunctionReference<"query">("functions/workspaceArtifacts:get"), reviews: makeFunctionReference<"query">("functions/prospectReviews:list"), assess: makeFunctionReference<"mutation">("functions/prospectReviews:assess"), importArtifact: makeFunctionReference<"action">( "functions/workspaceArtifacts:importArtifact", ), resolveCompanyList: makeFunctionReference<"action">( "functions/companyLists:resolveCompanyList", ), }; const ENRICHMENT_FILTERS = [ "all", "enriched", "in_progress", "no_contacts", "failed", "not_enriched", ]; function rowIndexesFlag(args: string[]): number[] | undefined { const rowsFlag = getFlag(args, "--rows"); return rowsFlag === undefined ? undefined : rowsFlag.split(",").map((value) => { if (!/^\d+$/.test(value.trim())) throw new FindCliError( "--rows must be comma-separated original zero-based row indices", ); const index = Number(value); if (!Number.isSafeInteger(index)) throw new FindCliError("Invalid row index"); return index; }); } const CONTACT_FIELDS = ["email", "phone", "personal_email"] as const; type ContactField = (typeof CONTACT_FIELDS)[number]; /** One enrichSelected call accepts at most this many profiles. */ const ENRICH_BATCH = 100; const ENRICH_WAIT_POLL_MS = 10_000; /** Stays under the sandbox's 300s default foreground bash budget. */ const ENRICH_WAIT_DEFAULT_SECONDS = 240; type SavedRowEntry = { index: number; row: unknown }; /** Page a saved list by original index until isDone. */ async function savedListEntries( ctx: FindCliContext, artifactId: string, enrichment: string, ): Promise { const entries: SavedRowEntry[] = []; let cursor: string | null | undefined; do { const page = (await callAction(ctx, api.listRows, { artifactId, limit: 100, enrichment, ...(cursor ? { cursor } : {}), })) as { page: SavedRowEntry[]; isDone: boolean; continueCursor: string | null; }; entries.push(...page.page); cursor = page.isDone ? null : page.continueCursor; } while (cursor); return entries; } async function enrichmentCounts( ctx: FindCliContext, artifactId: string, selected: ReadonlySet, ) { const counts: Record = {}; for (const state of ["in_progress", "enriched", "no_contacts", "failed"]) { const rows = await savedListEntries(ctx, artifactId, state); counts[state] = rows.filter((entry) => selected.has(entry.index)).length; } return counts; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function enrichmentFlag(args: string[]): string | undefined { const value = getFlag(args, "--enrichment"); if (value !== undefined && !ENRICHMENT_FILTERS.includes(value)) throw new FindCliError( "--enrichment must be " + ENRICHMENT_FILTERS.join("|"), ); return value; } function shift(args: string[], expected: string): string { try { return shiftRaw(args, expected); } catch (error) { if (error instanceof CliArgError) throw new FindCliError(error.message); throw error; } } function assertNoUnknownFlags(args: readonly string[], command: string): void { try { assertNoUnknownFlagsRaw(args, command); } catch (error) { if (error instanceof CliArgError) throw new FindCliError(error.message); throw error; } } type LeadsFindRange = { min?: number; max?: number }; type LeadsFindFlags = { titles?: string[]; pastTitles?: string[]; personNames?: string[]; locations?: string[]; seniorities?: string[]; pastSeniorities?: string[]; profileKeywords?: string[]; companyKeywords?: string[]; industries?: string[]; functions?: string[]; subFunctions?: string[]; companyDomain?: string; companyDomains?: string[]; companyNames?: string[]; pastCompanyNames?: string[]; companyHeadquarters?: string[]; companyTypes?: string[]; universities?: string[]; skills?: string[]; languages?: string[]; headcount?: string[]; yearsInRole?: LeadsFindRange; yearsAtCompany?: LeadsFindRange; daysSinceJobChange?: LeadsFindRange; foundedYears?: LeadsFindRange; keywords?: string; companyList?: string; exactMatch?: boolean; limit?: number; pageToken?: string; name?: string; }; /** Parse `min:max` (either side optional; a bare number means min). */ function rangeFlag(args: string[], name: string): LeadsFindRange | undefined { const raw = getFlag(args, name); if (!raw) return undefined; const [minRaw, maxRaw] = raw.includes(":") ? raw.split(":", 2) : [raw, ""]; const parsePart = (part: string, label: string): number | undefined => { const trimmed = part.trim(); if (!trimmed) return undefined; const parsed = Number(trimmed); if (!Number.isInteger(parsed) || parsed < 0) { throw new FindCliError( `${name} ${label} must be a non-negative integer (use min:max, e.g. 2:10)`, ); } return parsed; }; const min = parsePart(minRaw ?? "", "min"); const max = parsePart(maxRaw ?? "", "max"); if (min === undefined && max === undefined) { throw new FindCliError(`${name} must be min:max, e.g. 2:10, 2: or :10`); } return { min, max }; } function parseLeadsFindFlags(args: string[]): LeadsFindFlags { const flags: LeadsFindFlags = { titles: csvFlag(args, "--titles", "titles"), pastTitles: csvFlag(args, "--past-titles", "pastTitles"), personNames: csvFlag(args, "--person-names"), locations: csvFlag(args, "--locations", "locations"), seniorities: csvFlag(args, "--seniorities", "seniorities"), pastSeniorities: csvFlag(args, "--past-seniorities", "pastSeniorities"), profileKeywords: csvFlag(args, "--profile-keywords", "profileKeywords"), companyKeywords: csvFlag(args, "--company-keywords", "companyKeywords"), industries: csvFlag(args, "--industries", "industries"), functions: csvFlag(args, "--functions", "functions"), subFunctions: csvFlag(args, "--sub-functions", "subFunctions"), companyDomain: getFlag(args, "--company-domain"), companyDomains: csvFlag(args, "--company-domains"), companyNames: csvFlag(args, "--company-names"), pastCompanyNames: csvFlag(args, "--past-company-names"), companyHeadquarters: csvFlag(args, "--company-hq", "companyHeadquarters"), companyTypes: csvFlag(args, "--company-types", "companyTypes"), universities: csvFlag(args, "--universities", "universities"), skills: csvFlag(args, "--skills", "skills"), languages: csvFlag(args, "--languages", "languages"), headcount: csvFlag(args, "--headcount", "headcount"), yearsInRole: rangeFlag(args, "--years-in-role"), yearsAtCompany: rangeFlag(args, "--years-at-company"), daysSinceJobChange: rangeFlag(args, "--days-since-job-change"), foundedYears: rangeFlag(args, "--founded-years"), keywords: getFlag(args, "--keywords"), companyList: getFlag(args, "--company-list"), // Free-text filters default to substring matching. `--exact` restores // whole-string matching for a caller who wants the narrow list. exactMatch: hasFlag(args, "--exact") ? true : undefined, limit: parseLimit(args), pageToken: getFlag(args, "--page-token"), name: getFlag(args, "--save-as"), }; assertNoUnknownFlags(args, "leads find"); const hasFilter = Object.entries(flags).some( ([key, value]) => !["limit", "pageToken", "name", "exactMatch"].includes(key) && value !== undefined, ); if (!hasFilter) { throw new FindCliError( "dench leads find requires at least one of --titles, --locations, --company-domain, --keywords, --industries, --seniorities (or any other filter flag — run `dench leads help`)", ); } return flags; } export function assertLeadsFindArgs(args: string[]): void { const copy = [...args]; hasFlag(copy, "--json"); const verb = copy[0]; if (verb !== "find") return; parseLeadsFindFlags(copy.slice(1)); } /** * Split one flag value into filter entries without shredding names that * legitimately contain a comma. * * `--industries "Technology, Information and Internet"` used to become TWO * bogus industries and return zero rows, and 26 of the 490 shipped industries * (plus every "University of California, X") carry a comma. Three ways out, in * order of how much the caller has to know: * * 1. The whole value is a known vocabulary entry -> keep it whole. * 2. Otherwise split on commas OUTSIDE quotes, then greedily re-join the * longest run of adjacent parts that reconstructs a vocabulary entry. * 3. Quote a value, or repeat the flag, to force the split you want. * * Greedy-longest is only ambiguous where a comma-containing entry's first * segment is also an entry on its own — 2 of 490 industries ("Farming, * Ranching, Forestry" and "Museums, Historical Sites, and Zoos"). Typing the * canonical string should yield the canonical value, and repeating the flag * expresses the other reading. */ export function splitListValue(raw: string, vocabulary?: string[]): string[] { const known = new Map( (vocabulary ?? []).map((value) => [value.trim().toLowerCase(), value]), ); const whole = raw.trim(); const wholeMatch = known.get(whole.toLowerCase()); if (wholeMatch) return [wholeMatch]; const parts: string[] = []; let current = ""; let quote: '"' | "'" | null = null; for (let index = 0; index < raw.length; index += 1) { const char = raw[index]; if (quote) { // Delimiters wrap the whole value; an apostrophe in McDonald's is data. if (char === quote && /^\s*(,|$)/.test(raw.slice(index + 1))) quote = null; else current += char; continue; } if ((char === '"' || char === "'") && current.trim() === "") { quote = char; continue; } if (char === ",") { parts.push(current); current = ""; continue; } current += char; } parts.push(current); const trimmed = parts.map((part) => part.trim()).filter(Boolean); if (!known.size) return trimmed; const out: string[] = []; for (let i = 0; i < trimmed.length; ) { let matched: { value: string; next: number } | null = null; for (let end = trimmed.length; end > i; end--) { const joined = trimmed.slice(i, end).join(", "); const hit = known.get(joined.toLowerCase()); if (hit) { matched = { value: hit, next: end }; break; } } if (matched) { out.push(matched.value); i = matched.next; continue; } out.push(trimmed[i] as string); i += 1; } return out; } function csvFlag( args: string[], name: string, catalogKey?: keyof typeof FIND_FILTER_OPTION_CATALOG, ): string[] | undefined { const vocabulary = catalogKey ? FIND_FILTER_OPTION_CATALOG[catalogKey]?.groups.flatMap( (group) => group.values, ) : undefined; // Every occurrence accumulates, so repeating the flag is the escape hatch // for a value the splitter would otherwise divide. const values: string[] = []; for (;;) { const raw = getFlag(args, name); if (raw === undefined) break; values.push(...splitListValue(raw, vocabulary)); } return values.length ? values : undefined; } function parseLimit(args: string[]): number | undefined { const raw = getFlag(args, "--limit"); if (!raw) return undefined; const parsed = Number(raw); if (!Number.isInteger(parsed) || parsed < 1) { throw new FindCliError("--limit must be a positive integer"); } return Math.min(parsed, 5000); } function out(ctx: FindCliContext, value: unknown): void { console.log(JSON.stringify(value, null, ctx.jsonOutput ? 2 : 0)); } async function callQuery( ctx: FindCliContext, fn: Parameters[0], args: Record = {}, ): Promise { return await ctx.convex.query(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } async function callAction( ctx: FindCliContext, fn: Parameters[0], args: Record = {}, ): Promise { return await ctx.convex.action(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } /* ─── Company-list filter (--company-list) ─── */ type ResolvedCompanyList = { source: "artifact" | "view"; title: string; domains: string[]; names: string[]; totalCompanies: number; truncated: boolean; }; /** Convex document ids are long lowercase alphanumeric strings. */ const COMPANY_LIST_ID_RE = /^[a-z0-9]{25,40}$/; /** * `--company-list` accepts a saved-search artifact id (from * `dench companies find` / `dench lists`) or a Company saved-view name. * Id-shaped values are tried as artifacts first; anything else (or an * id that doesn't resolve) is looked up as a view name. */ async function resolveCompanyListFlag( ctx: FindCliContext, value: string, ): Promise { const trimmed = value.trim(); if (!trimmed) { throw new FindCliError( "--company-list requires a list artifact id or a Company view name", ); } if (COMPANY_LIST_ID_RE.test(trimmed)) { try { return (await callAction(ctx, api.resolveCompanyList, { artifactId: trimmed, })) as ResolvedCompanyList; } catch { // Not an artifact (or not ours) — fall through to view-name lookup. } } try { return (await callAction(ctx, api.resolveCompanyList, { viewName: trimmed, })) as ResolvedCompanyList; } catch (error) { throw new FindCliError( error instanceof Error ? error.message : String(error), ); } } /** Saved-filter entry: the reference (re-resolved on hydrate) + title. */ function companyListFilterEntry( ref: string, resolved: ResolvedCompanyList, ): Record { return resolved.source === "artifact" ? { artifactId: ref.trim(), title: resolved.title } : { viewName: ref.trim(), title: resolved.title }; } /** Output block mirroring /api/workspace/find's `companyList` response. */ function companyListInfo(resolved: ResolvedCompanyList) { return { source: resolved.source, title: resolved.title, companiesUsed: resolved.domains.length + resolved.names.length, totalCompanies: resolved.totalCompanies, truncated: resolved.truncated, }; } function mergeUnique( base: string[] | undefined, extra: string[] | undefined, ): string[] | undefined { const merged = [...new Set([...(base ?? []), ...(extra ?? [])])]; return merged.length > 0 ? merged : undefined; } function findTitle( kind: "people" | "companies" | "jobs", parts: string[], ): string { const first = parts.find((part) => part.length > 0); if (kind === "people") return first ? `People: ${first}` : "People search"; if (kind === "companies") { return first ? `Companies: ${first}` : "Company search"; } return first ? `Jobs: ${first}` : "Job search"; } async function persist( ctx: FindCliContext, args: { createdFrom: "find_people" | "find_companies" | "find_jobs"; title: string; filters: Record; people?: unknown[]; companies?: unknown[]; jobs?: unknown[]; }, ): Promise { return await callAction(ctx, api.saveFind, args); } export function leadsHelp(): void { console.log(`Dench leads Usage: dench leads find --titles "VP Sales" --locations "San Francisco" [--company-domain acme.com] [--save-as "Q3 targets"] [--limit 500] [--page-token ] [--json] dench leads find --keywords "fintech" [--industries SaaS] [--seniorities director,vp] [--json] dench leads find --company-list --titles "VP Sales" [--json] dench leads filters [--filter titles|locations|company-hq|universities|skills|keywords|seniorities|functions|sub-functions|industries|headcount|company-types|languages] [--search "query"] [--json] Filter flags (all comma-separated lists unless noted): People: --titles --past-titles --person-names --seniorities --functions --past-seniorities --profile-keywords --sub-functions --languages --universities --skills --locations Company: --company-names --past-company-names --company-domain (single) --company-domains --company-hq --company-types --industries --company-keywords (business focus: SaaS, B2B, Cloud) --headcount (buckets like 11-50,51-200) List: --company-list — filter by a saved company list (a \`dench companies find\` artifact or a Company saved view). Resolves to the list's CURRENT companies on each run; lists up to ~1000 companies are searched in one go. Ranges: --years-in-role --years-at-company --days-since-job-change --founded-years (min:max, e.g. 2:10, 2: or :10) Query: --keywords "Engineer" (current-title text only) Match: --exact match free-text filters as WHOLE strings Free-text filters (titles, locations, company names, universities, skills) match as substrings by default, so --titles "Founder" also finds "Co-Founder" and "Founder & CEO". Pass --exact for whole-string matching when you want the narrow list. Identifiers (domains, LinkedIn URLs) and enum filters are always matched exactly either way. --past-seniorities matches any past role, independently of --past-company-names. --profile-keywords searches headline/about and current/past work descriptions. --company-keywords searches the current employer's description/business focus, not legal company type. Description terms always use loose word matching. These filters qualify returned profiles; missing evidence does not match. A value that itself contains a comma is kept whole when it matches a known vocabulary entry, so --industries "Technology, Information and Internet" is one industry, not two. Quote it, or repeat the flag, to be explicit. \`dench leads filters\` prints the accepted values: enum filters (seniorities, functions, industries, headcount, company-types, languages) expect those exact values; the rest are free text with curated suggestions. Finds people through Dench search and writes a workspace list artifact. Pass --save-as to name the list (otherwise a title is generated). Manage lists with \`dench lists\`; import with \`dench artifacts import --to lead\`. Typical chain: dench companies find … --json → grab artifactId → dench leads find --company-list --titles "VP Sales". `); } /* ─── dench leads filters ─── */ /** kebab-case flag spellings accepted by `--filter` alongside catalog keys. */ const FILTER_KEY_ALIASES: Record = { "past-titles": "pastTitles", "past-seniorities": "pastSeniorities", "profile-keywords": "profileKeywords", "company-keywords": "companyKeywords", "company-hq": "companyHeadquarters", "company-types": "companyTypes", "sub-functions": "subFunctions", }; /** * Print the Lead Finder filter-option catalogs (offline; bundled data). * Enum filters list exact gateway vocabularies; suggestion filters list * curated starting points — custom free text always works for those. */ export function runFindFiltersCommand(opts: { args: string[]; jsonOutput?: boolean; }): void { const args = [...opts.args]; const jsonOutput = hasFlag(args, "--json") || opts.jsonOutput === true; const filterRaw = getFlag(args, "--filter"); const search = getFlag(args, "--search")?.trim().toLowerCase(); assertNoUnknownFlags(args, "leads filters"); const resolveKey = (raw: string): string => { const aliased = FILTER_KEY_ALIASES[raw.toLowerCase()]; if (aliased) return aliased; const match = Object.keys(FIND_FILTER_OPTION_CATALOG).find( (key) => key.toLowerCase() === raw.toLowerCase(), ); if (match) return match; throw new FindCliError( `Unknown filter: ${raw}. Valid filters: ${[ ...Object.keys(FIND_FILTER_OPTION_CATALOG), ...Object.keys(FILTER_KEY_ALIASES), ].join(", ")}`, ); }; const keys = filterRaw ? [resolveKey(filterRaw)] : Object.keys(FIND_FILTER_OPTION_CATALOG); const filters: Record = {}; for (const key of keys) { const entry = FIND_FILTER_OPTION_CATALOG[key]; const groups = search ? entry.groups .map((group) => ({ group: group.group, values: group.values.filter((value) => value.toLowerCase().includes(search), ), })) .filter((group) => group.values.length > 0) : entry.groups; filters[key] = { label: entry.label, kind: entry.kind, groups }; } console.log( JSON.stringify( { note: "kind=enum values are exact gateway vocabularies; kind=suggestion values are curated starting points and custom free text is accepted.", filters, }, null, jsonOutput ? 2 : 0, ), ); } export function companiesHelp(): void { console.log(`Dench companies Usage: dench companies related --domain acme.com --kind employees|founders|funding|investors|acquisitions|investments [--limit 25] [--page-token ] [--json] dench companies find --name "Acme" [--domain acme.com] [--keywords fintech] [--industries SaaS] [--locations "San Francisco"] [--linkedin https://linkedin.com/company/acme] [--exact] [--save-as "Target accounts"] [--limit 500] [--page-token ] [--json] Rows include a headquarters line (street where the provider has one). Finds companies through Dench search and writes a workspace list artifact. Pass --save-as to name the list (otherwise a title is generated). Manage lists with \`dench lists\`; import with \`dench artifacts import --to company\`. The artifact id doubles as a company list: pass it to \`dench leads find --company-list \` or \`dench jobs find --company-list \` to search people or job postings at exactly these companies. `); } export function jobsHelp(): void { console.log(`Dench jobs Usage: dench jobs find --titles "Account Executive" --locations "San Francisco" [--company-domain acme.com] [--keywords remote] [--save-as "AE openings"] [--limit 500] [--page-token ] [--json] dench jobs find --company-list [--titles "Account Executive"] [--posted-after 2026-09-01] [--posted-before 2026-09-30] [--json] Company filters: --company (name), --company-domain (single), --company-domains (comma-separated), or --company-list (a saved company list: a \`dench companies find\` artifact id or a Company saved-view name — resolved to its current companies on each run). Finds job postings through Dench search and writes a workspace list artifact. Pass --save-as to name the list (otherwise a title is generated). Manage lists with \`dench lists\`; import with \`dench artifacts import --to job_posting\`. `); } export function workspaceArtifactsHelp(): void { console.log(`Dench artifacts Usage: dench artifacts [--limit 25] [--json] dench artifacts list [--limit 25] [--json] dench artifacts show [--json] dench artifacts import --to lead|company|job_posting [--rows 0,4,9] [--enrichment enriched] [--status "Contacted"] [--view "Q3 outbound"] [--pin] [--json] \`list\` / \`show\` / \`import\` operate on find results (lead lists and job searches). Bare \`dench artifacts\` still lists agent suggested-work artifacts. Rename/delete saved searches with \`dench lists rename|delete\`. `); } export function listsHelp(): void { console.log(listsHelpText()); } export function listsHelpText(): string { return `Dench lists Usage: dench lists [--search "September"] [--limit 25] [--cursor ] [--json] dench lists show [--json] dench lists save --title "Shortlist" --people-file [--filters-json '{...}'] [--saved] [--json] dench lists save --title "Target accounts" --companies-file [--json] dench lists save --title "Open roles" --jobs-file [--json] dench lists rows [--enrichment all|enriched|in_progress|no_contacts|failed|not_enriched] [--limit 100] [--cursor ] [--json] dench lists enrich [--fields email,phone,personal_email] [--rows 0,4,9] [--wait [--timeout 240]] [--json] dench lists rename "New name" [--json] dench lists update [--title "New name"] [--filters-json '{"titles":["CTO"]}'] [--json] dench lists delete [--json] Lists are saved searches from Find (people / companies / job postings). People lists are not CRM Leads. Searching and saving do not reveal new contact details; there is no mandatory per-profile ICP decision. Import only on explicit request, with the requested configured CRM status. Follow pagination until isDone, even across empty filtered pages. --rows uses original zero-based indices from lists rows. Enrich buys contact details (default: work email) for every row, or only --rows, and saves them to the list. It costs credits, so run it only when the user asks for contacts. Found, empty and in-flight lookups are never bought twice, so rerunning is safe. --wait polls until lookups finish (default 240s) and prints status counts. dench lists reviews [--limit 100] [--cursor ] [--json] dench lists assess --row-json '' --fit likely_fit|unlikely_fit|unknown --score 0-100 --reason '' [--json] dench lists assess --assessments-file [--json] Assessments files contain 1–100 {row, suggestion: {fit, score, reason}} items. Scores are AI relevance to the saved ICP, not conversion probabilities or CRM qualification. Create one with \`dench leads|companies|jobs find --save-as "Name"\`. Consolidate selected search results with \`dench lists save\`: choose --people-file, --companies-file or --jobs-file. The JSON can be an array or a search response containing the matching people, companies or jobs array. It saves a new list without searching, enriching, changing source lists, or importing CRM rows. Pass --saved to show it in Find; otherwise it stays an unlisted draft accessible by id. Add a saved people list to a campaign with \`dench campaign add --list \`. Use \`--preview\` first to review the selection without importing or enrolling anyone. Delete removes the list, its exported CSV/JSONL files, and any linked watch. CRM lists (saved views on Leads/Companies/Job Postings) are managed with \`dench crm views list|get|create|rename|delete\`. `; } /** * `dench lists …` — full management of saved searches. Returns false * only for `help` so the caller can print usage. */ export async function runListsCommand(opts: { convex: ConvexHttpClient; args: string[]; jsonOutput?: boolean; sessionToken?: string; }): Promise { const args = [...opts.args]; const ctx: FindCliContext = { convex: opts.convex, args, jsonOutput: hasFlag(args, "--json") || opts.jsonOutput === true, sessionToken: opts.sessionToken, }; const verb = args[0]; if (verb === "help" || verb === "--help") return false; if (verb === undefined || verb === "list" || verb?.startsWith("--")) { if (verb === "list") args.shift(); const limit = Number(getFlag(args, "--limit") ?? 25); const cursor = getFlag(args, "--cursor") ?? null; const search = getFlag(args, "--search"); if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new FindCliError("--limit must be between 1 and 100"); assertNoUnknownFlags(args, "lists"); out( ctx, await callQuery(ctx, api.listSaved, { paginationOpts: { numItems: limit, cursor }, search, }), ); return true; } args.shift(); if (verb === "save") { const title = getFlag(args, "--title")?.trim(); const sources = [ { kind: "people", file: getFlag(args, "--people-file"), label: "person", names: ["fullName", "name"], }, { kind: "companies", file: getFlag(args, "--companies-file"), label: "company", names: ["name"], }, { kind: "jobs", file: getFlag(args, "--jobs-file"), label: "job", names: ["title"], }, ].filter((source) => source.file); const filtersJson = getFlag(args, "--filters-json"); const saved = hasFlag(args, "--saved"); assertNoUnknownFlags(args, "lists save"); if (!title || sources.length !== 1) { throw new FindCliError( "dench lists save requires --title and exactly one of --people-file, --companies-file or --jobs-file", ); } const source = sources[0]; const flag = `--${source.kind}-file`; let rows: unknown; let filters: unknown; try { const content: unknown = JSON.parse( await readFile(source.file!, "utf-8"), ); rows = Array.isArray(content) ? content : content && typeof content === "object" ? (content as Record)[source.kind] : content; } catch { throw new FindCliError( `${flag} must be a readable JSON file containing an array of ${source.label} rows`, ); } if ( !Array.isArray(rows) || rows.length === 0 || rows.some( (row) => !row || typeof row !== "object" || Array.isArray(row) || !source.names.some( (name) => typeof row[name] === "string" && row[name].trim(), ), ) ) { throw new FindCliError( `${flag} must contain a non-empty array of ${source.label} rows, each with ${source.names.join(" or ")}`, ); } if (filtersJson !== undefined) { try { filters = JSON.parse(filtersJson); } catch { throw new FindCliError("--filters-json must be a JSON object"); } if (!filters || typeof filters !== "object" || Array.isArray(filters)) { throw new FindCliError("--filters-json must be a JSON object"); } } out( ctx, await callAction(ctx, api.saveFind, { createdFrom: `find_${source.kind}`, title, [source.kind]: rows, ...(saved ? { saved: true } : {}), ...(filters !== undefined ? { filters } : {}), }), ); return true; } if (verb === "rows") { const artifactId = shift(args, "list id"); const limit = Number(getFlag(args, "--limit") ?? 100); const cursor = getFlag(args, "--cursor"); const enrichment = enrichmentFlag(args); if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new FindCliError("--limit must be between 1 and 100"); assertNoUnknownFlags(args, "lists rows"); out( ctx, await callAction(ctx, api.listRows, { artifactId, limit, cursor, enrichment, }), ); return true; } if (verb === "reviews") { const artifactId = shift(args, "list id"); const limit = Number(getFlag(args, "--limit") ?? 50); const cursor = getFlag(args, "--cursor") ?? null; if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new FindCliError("--limit must be between 1 and 100"); assertNoUnknownFlags(args, "lists reviews"); out( ctx, await callQuery(ctx, api.reviews, { artifactId, paginationOpts: { numItems: limit, cursor }, }), ); return true; } if (verb === "assess") { const artifactId = shift(args, "list id"); const rowJson = getFlag(args, "--row-json"); const assessmentsFile = getFlag(args, "--assessments-file"); const fit = getFlag(args, "--fit"); const scoreRaw = getFlag(args, "--score"); const reason = getFlag(args, "--reason"); assertNoUnknownFlags(args, "lists assess"); if (assessmentsFile) { if ( rowJson !== undefined || fit !== undefined || scoreRaw !== undefined || reason !== undefined ) throw new FindCliError( "Use --assessments-file alone, or --row-json with --fit, --score, and --reason.", ); let input: unknown; try { input = JSON.parse(await readFile(assessmentsFile, "utf-8")); } catch { throw new FindCliError( "--assessments-file must be a readable JSON array of assessments.", ); } const assessments = parseProspectAssessments(input); let saved = 0; try { for (const assessment of assessments) { await ctx.convex.mutation(api.assess, { artifactId, ...assessment, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); saved++; } } catch (error) { throw new FindCliError( `Saved ${saved} of ${assessments.length} assessments. Retry the file to continue; assessments are replaced, not duplicated. ${error instanceof Error ? error.message : "Assessment failed."}`, ); } out(ctx, { assessed: saved }); return true; } if ( !rowJson || !reason || !fit || !["likely_fit", "unlikely_fit", "unknown"].includes(fit) ) throw new FindCliError( "Use --row-json, --fit likely_fit|unlikely_fit|unknown, and --reason. This records an AI suggestion, not human approval.", ); let row: unknown; try { row = JSON.parse(rowJson); } catch { throw new FindCliError("--row-json must be valid JSON"); } out( ctx, await ctx.convex.mutation(api.assess, { artifactId, ...parseProspectAssessments([ { row, suggestion: { fit, reason, ...(scoreRaw !== undefined ? { score: scoreRaw.trim() ? Number(scoreRaw) : NaN } : {}), }, }, ])[0], ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never), ); return true; } if (verb === "enrich") { const artifactId = shift(args, "list id"); const fieldsRaw = getFlag(args, "--fields") ?? "email"; const fields = [ ...new Set(fieldsRaw.split(",").map((field) => field.trim())), ]; if ( !fields.length || fields.some((field) => !CONTACT_FIELDS.includes(field as ContactField)) ) throw new FindCliError( `--fields must be a comma-separated subset of ${CONTACT_FIELDS.join(",")}`, ); const rowIndexes = rowIndexesFlag(args); const wait = hasFlag(args, "--wait"); const timeoutRaw = getFlag(args, "--timeout"); const timeoutSeconds = timeoutRaw === undefined ? ENRICH_WAIT_DEFAULT_SECONDS : Number(timeoutRaw); if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1) throw new FindCliError("--timeout must be a positive number of seconds"); if (timeoutRaw !== undefined && !wait) throw new FindCliError("--timeout only applies with --wait"); assertNoUnknownFlags(args, "lists enrich"); // The server skips fields already found, looked up empty, or in flight, // so sending every selected row never buys the same contact twice. let entries = await savedListEntries(ctx, artifactId, "all"); if (rowIndexes) { const byIndex = new Map(entries.map((entry) => [entry.index, entry])); const missing = rowIndexes.filter((index) => !byIndex.has(index)); if (missing.length) throw new FindCliError( `Row ${missing.join(", ")} is not in this list. Use original indices from dench lists rows.`, ); entries = [...new Set(rowIndexes)].map( (index) => byIndex.get(index) as SavedRowEntry, ); } if (!entries.length) throw new FindCliError("This list has no rows."); let queued = 0; let reused = 0; for (let start = 0; start < entries.length; start += ENRICH_BATCH) { const batch = entries.slice(start, start + ENRICH_BATCH); try { const result = (await callAction(ctx, api.enrichSelected, { artifactId, rows: batch.map((entry) => entry.row), fields, })) as { queued: number; reused: number }; queued += result.queued; reused += result.reused; } catch (error) { const message = error instanceof Error ? error.message : "Enrichment failed."; // The first batch never committed, so there is nothing to resume. if (start === 0) throw new FindCliError(message); throw new FindCliError( `Started ${queued} lookups for the first ${start} of ${entries.length} profiles. Rerun the same command to continue; finished and in-flight lookups are not bought again. ${message}`, ); } } const summary = { artifactId, fields, profiles: entries.length, queued, reused, }; const readBack = `dench lists rows ${artifactId} --enrichment enriched --limit 100 --json`; if (!wait) { out(ctx, { ...summary, next: readBack }); return true; } const deadline = Date.now() + timeoutSeconds * 1000; const selected = new Set(entries.map((entry) => entry.index)); const inProgress = async () => (await savedListEntries(ctx, artifactId, "in_progress")).filter((entry) => selected.has(entry.index), ).length; while ((await inProgress()) && Date.now() < deadline) await sleep(Math.min(ENRICH_WAIT_POLL_MS, deadline - Date.now())); const counts = await enrichmentCounts(ctx, artifactId, selected); out(ctx, { ...summary, done: !counts.in_progress, counts, next: counts.in_progress ? `Still running. Check again with: dench lists rows ${artifactId} --enrichment in_progress --json` : counts.failed ? `dench lists rows ${artifactId} --enrichment failed --limit 100 --json` : readBack, }); return true; } if (verb === "show") { const artifactId = shift(args, "list id"); assertNoUnknownFlags(args, "lists show"); out(ctx, await callQuery(ctx, api.get, { artifactId })); return true; } if (verb === "rename") { const artifactId = shift(args, "list id"); const title = getFlag(args, "--title") ?? shift(args, "new name"); assertNoUnknownFlags(args, "lists rename"); out( ctx, await ctx.convex.mutation(api.rename, { artifactId, title, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never), ); return true; } if (verb === "update") { const artifactId = shift(args, "list id"); const title = getFlag(args, "--title"); const filtersJson = getFlag(args, "--filters-json"); assertNoUnknownFlags(args, "lists update"); let filters: unknown; if (filtersJson !== undefined) { try { filters = JSON.parse(filtersJson); } catch { throw new FindCliError("--filters-json must be valid JSON"); } } if (title === undefined && filters === undefined) { throw new FindCliError( "dench lists update requires --title and/or --filters-json", ); } out( ctx, await callAction(ctx, api.updateFind, { artifactId, ...(title !== undefined ? { title } : {}), ...(filters !== undefined ? { filters } : {}), }), ); return true; } if (verb === "delete") { const artifactId = shift(args, "list id"); assertNoUnknownFlags(args, "lists delete"); out( ctx, await ctx.convex.mutation(api.remove, { artifactId, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never), ); return true; } throw new FindCliError( `Unknown lists verb: ${verb}. Try list, show, rename, update, delete.`, ); } export function assertJobsFindArgs(args: string[]): void { const copy = [...args]; hasFlag(copy, "--json"); if (copy[0] !== "find") return; if ( !copy.includes("--titles") && !copy.includes("--locations") && !copy.includes("--company-domain") && !copy.includes("--company-domains") && !copy.includes("--company") && !copy.includes("--company-list") && !copy.includes("--keywords") ) { throw new FindCliError( "dench jobs find requires at least one of --titles, --locations, --company-domain, --company-domains, --company, --company-list, --keywords, --posted-after, --posted-before", ); } } export function assertCompaniesFindArgs(args: string[]): void { const copy = [...args]; hasFlag(copy, "--json"); if (copy[0] !== "find") return; if ( !copy.includes("--domain") && !copy.includes("--name") && !copy.includes("--keywords") && !copy.includes("--industries") && !copy.includes("--locations") && !copy.includes("--linkedin") && !copy.includes("--headcount") ) { throw new FindCliError( "dench companies find requires at least one of --name, --domain, --keywords, --industries, --locations, --linkedin, --headcount", ); } } export async function runLeadsCommand(opts: { convex: ConvexHttpClient; args: string[]; jsonOutput?: boolean; sessionToken?: string; enrichmentGateway?: Pick; }): Promise { const args = [...opts.args]; const jsonFromArgs = hasFlag(args, "--json"); const ctx: FindCliContext = { convex: opts.convex, args, jsonOutput: opts.jsonOutput === true || jsonFromArgs, sessionToken: opts.sessionToken, enrichmentGateway: opts.enrichmentGateway, }; const verb = args.shift(); if (!verb || verb === "help" || verb === "--help") { leadsHelp(); return; } if (verb === "filters") { runFindFiltersCommand({ args, jsonOutput: ctx.jsonOutput }); return; } if (verb !== "find") { throw new FindCliError(`Unknown leads verb: ${verb}`); } const { name: listName, ...flags } = parseLeadsFindFlags(args); // Resolve --company-list fresh per search so the filter tracks the // list's CURRENT companies (domains exact, name fallback for rows // without a domain). const companyList = flags.companyList ? await resolveCompanyListFlag(ctx, flags.companyList) : undefined; try { const result = await searchPeople( { titles: flags.titles, pastTitles: flags.pastTitles, personNames: flags.personNames, locations: flags.locations, seniorities: flags.seniorities, pastSeniorities: flags.pastSeniorities, profileKeywords: flags.profileKeywords, companyKeywords: flags.companyKeywords, jobFunctions: flags.functions, subFunctions: flags.subFunctions, industries: flags.industries, companyDomain: flags.companyDomain, companyDomains: flags.companyDomains, companyNames: flags.companyNames, companyScope: companyList ? { domains: companyList.domains, names: companyList.names } : undefined, pastCompanyNames: flags.pastCompanyNames, companyHeadquarters: flags.companyHeadquarters, companyTypes: flags.companyTypes, universities: flags.universities, skills: flags.skills, languages: flags.languages, headcountRanges: flags.headcount, yearsInRole: flags.yearsInRole, yearsAtCompany: flags.yearsAtCompany, daysSinceJobChange: flags.daysSinceJobChange, foundedYears: flags.foundedYears, keywords: flags.keywords, exactMatch: flags.exactMatch, limit: flags.limit, pageToken: flags.pageToken, }, ctx.enrichmentGateway, ); const people = asArray(result.people); // Persist every set filter so the saved list hydrates the full rail. const savedFilters: Record = {}; for (const [key, value] of Object.entries(flags)) { if (key === "pageToken" || value === undefined) continue; savedFilters[key] = value; } if (flags.companyList && companyList) { // Store the reference (not the resolved domains) so re-running the // saved search picks up the list's contents at that time. savedFilters.companyList = companyListFilterEntry( flags.companyList, companyList, ); } const artifact = await persist(ctx, { createdFrom: "find_people", title: listName ?? findTitle("people", [ flags.titles?.[0] ?? "", flags.keywords ?? "", flags.companyDomain ?? "", companyList?.title ?? "", ]), filters: savedFilters, people, }); // metadata carries total / nextPageToken — pass the token back via // --page-token to continue where this page stopped. out(ctx, { ...(artifact as Record), metadata: result.metadata, billing: result.billing ?? null, ...(companyList ? { companyList: companyListInfo(companyList) } : {}), }); } catch (error) { throw new FindCliError( error instanceof EnrichmentGatewayError || error instanceof Error ? error.message : String(error), ); } } export async function runJobsCommand(opts: { convex: ConvexHttpClient; args: string[]; jsonOutput?: boolean; sessionToken?: string; enrichmentGateway?: Pick; }): Promise { const args = [...opts.args]; const jsonFromArgs = hasFlag(args, "--json"); const ctx: FindCliContext = { convex: opts.convex, args, jsonOutput: opts.jsonOutput === true || jsonFromArgs, sessionToken: opts.sessionToken, enrichmentGateway: opts.enrichmentGateway, }; const verb = args.shift(); if (!verb || verb === "help" || verb === "--help") { jobsHelp(); return; } if (verb !== "find") { throw new FindCliError(`Unknown jobs verb: ${verb}`); } const titles = csvFlag(args, "--titles", "titles"); const locations = csvFlag(args, "--locations", "locations"); const companyDomain = getFlag(args, "--company-domain"); const companyDomains = csvFlag(args, "--company-domains"); const companyName = getFlag(args, "--company"); const keywords = getFlag(args, "--keywords"); const postedAfter = getFlag(args, "--posted-after"); const postedBefore = getFlag(args, "--posted-before"); const companyListRef = getFlag(args, "--company-list"); const limit = parseLimit(args); const pageToken = getFlag(args, "--page-token"); const listName = getFlag(args, "--save-as"); assertNoUnknownFlags(args, "jobs find"); if ( !titles && !locations && !companyDomain && !companyDomains && !companyName && !keywords && !companyListRef && !postedAfter && !postedBefore ) { throw new FindCliError( "dench jobs find requires at least one of --titles, --locations, --company-domain, --company-domains, --company, --company-list, --keywords", ); } const companyList = companyListRef ? await resolveCompanyListFlag(ctx, companyListRef) : undefined; try { const result = await searchJobs( { titles, locations, companyDomain, companyDomains: mergeUnique(companyDomains, companyList?.domains), companyName, companyNames: companyList?.names.length ? companyList.names : undefined, keywords, postedAfter, postedBefore, limit, pageToken, }, ctx.enrichmentGateway, ); const jobs = asArray(result.jobs); const artifact = await persist(ctx, { createdFrom: "find_jobs", title: listName ?? findTitle("jobs", [ titles?.[0] ?? "", companyName ?? "", keywords ?? "", companyList?.title ?? "", ]), filters: { titles, locations, companyDomain, companyDomains, companyName, keywords, postedAfter, postedBefore, limit, ...(companyListRef && companyList ? { companyList: companyListFilterEntry(companyListRef, companyList), } : {}), }, jobs, }); out(ctx, { ...(artifact as Record), metadata: result.metadata, ...(companyList ? { companyList: companyListInfo(companyList) } : {}), }); } catch (error) { throw new FindCliError( error instanceof EnrichmentGatewayError || error instanceof Error ? error.message : String(error), ); } } export async function runCompaniesCommand(opts: { convex: ConvexHttpClient; args: string[]; jsonOutput?: boolean; sessionToken?: string; enrichmentGateway?: Pick; }): Promise { const args = [...opts.args]; const jsonFromArgs = hasFlag(args, "--json"); const ctx: FindCliContext = { convex: opts.convex, args, jsonOutput: opts.jsonOutput === true || jsonFromArgs, sessionToken: opts.sessionToken, enrichmentGateway: opts.enrichmentGateway, }; const verb = args.shift(); if (!verb || verb === "help" || verb === "--help") { companiesHelp(); return; } if (verb === "related") { const domain = getFlag(args, "--domain"); const linkedinUrl = getFlag(args, "--linkedin"); const kind = getFlag(args, "--kind"); const limit = parseLimit(args); const pageToken = getFlag(args, "--page-token"); assertNoUnknownFlags(args, "companies related"); if (Boolean(domain) === Boolean(linkedinUrl)) throw new FindCliError("Provide exactly one of --domain or --linkedin."); if ( !kind || ![ "employees", "founders", "funding", "investors", "acquisitions", "investments", ].includes(kind) ) throw new FindCliError( "--kind must be employees, founders, funding, investors, acquisitions, or investments.", ); if (limit !== undefined && limit > 100) throw new FindCliError("--limit must be between 1 and 100."); const result = await companyRelatedRecords( { domain, linkedinUrl, kind: kind as CompanyRelatedKind, limit, pageToken, }, ctx.enrichmentGateway, ); console.log(JSON.stringify(result, null, 2)); return; } if (verb !== "find") { throw new FindCliError(`Unknown companies verb: ${verb}`); } const domain = getFlag(args, "--domain"); const name = getFlag(args, "--name"); const keywords = getFlag(args, "--keywords"); const industries = csvFlag(args, "--industries", "industries"); const locations = csvFlag(args, "--locations", "locations"); const linkedinUrl = getFlag(args, "--linkedin"); const headcountRanges = csvFlag(args, "--headcount", "headcount"); const exactMatch = hasFlag(args, "--exact") ? true : undefined; const limit = parseLimit(args); const pageToken = getFlag(args, "--page-token"); const listName = getFlag(args, "--save-as"); assertNoUnknownFlags(args, "companies find"); if ( !domain && !name && !keywords && !industries && !locations && !linkedinUrl && !headcountRanges ) { throw new FindCliError( "dench companies find requires at least one of --name, --domain, --keywords, --industries, --locations, --linkedin, --headcount", ); } try { const result = await searchCompanies( { domain, name, keywords, industries, locations, linkedinUrl, headcountRanges, exactMatch, limit, pageToken, }, ctx.enrichmentGateway, ); const artifact = await persist(ctx, { createdFrom: "find_companies", title: listName ?? findTitle("companies", [name ?? "", domain ?? "", keywords ?? ""]), filters: { domain, name, keywords, industries, locations, linkedinUrl, headcountRanges, limit, }, companies: asArray(result.companies), }); out(ctx, { ...(artifact as Record), metadata: result.metadata, billing: result.billing ?? null, }); } catch (error) { throw new FindCliError( error instanceof EnrichmentGatewayError || error instanceof Error ? error.message : String(error), ); } } export async function runWorkspaceArtifactsCommand(opts: { convex: ConvexHttpClient; args: string[]; jsonOutput?: boolean; sessionToken?: string; }): Promise { const args = [...opts.args]; const verb = args[0]; if (verb !== "list" && verb !== "show" && verb !== "import") { return false; } args.shift(); const ctx: FindCliContext = { convex: opts.convex, args, jsonOutput: hasFlag(args, "--json") || opts.jsonOutput === true, sessionToken: opts.sessionToken, }; if (verb === "list") { const limitRaw = getFlag(args, "--limit"); const limit = limitRaw ? Number(limitRaw) : 25; assertNoUnknownFlags(args, "artifacts list"); out(ctx, await callQuery(ctx, api.list, { limit })); return true; } if (verb === "show") { const artifactId = shift(args, "artifact id"); assertNoUnknownFlags(args, "artifacts show"); out(ctx, await callQuery(ctx, api.get, { artifactId })); return true; } const artifactId = shift(args, "artifact id"); const to = getFlag(args, "--to"); if (to !== "lead" && to !== "job_posting" && to !== "company") { throw new FindCliError( "dench artifacts import --to lead|company|job_posting [--view ...] [--pin]", ); } const viewName = getFlag(args, "--view"); const pin = hasFlag(args, "--pin"); const status = getFlag(args, "--status"); const enrichment = enrichmentFlag(args); const rowIndexes = rowIndexesFlag(args); assertNoUnknownFlags(args, "artifacts import"); out( ctx, await callAction(ctx, api.importArtifact, { artifactId, to, viewName, pin, status, enrichment, rowIndexes, }), ); return true; }