@dench.com/cli 2.8.1-staging.110 → 2.8.3-staging.117

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/leads.ts ADDED
@@ -0,0 +1,1035 @@
1
+ /**
2
+ * `dench leads find`, `dench jobs find`, and workspace artifact
3
+ * list/show/import. Find writes a durable table artifact; import is the
4
+ * same path as the chat card.
5
+ */
6
+ import type { ConvexHttpClient } from "convex/browser";
7
+ import { makeFunctionReference } from "convex/server";
8
+ import {
9
+ assertNoUnknownFlags as assertNoUnknownFlagsRaw,
10
+ CliArgError,
11
+ getFlag,
12
+ hasFlag,
13
+ shift as shiftRaw,
14
+ } from "./lib/cli-args";
15
+ import {
16
+ EnrichmentGatewayError,
17
+ type EnrichmentGatewayOptions,
18
+ searchCompanies,
19
+ searchJobs,
20
+ searchPeople,
21
+ } from "./lib/enrichment-gateway";
22
+ import { FIND_FILTER_OPTION_CATALOG } from "./lib/find-filter-options.generated";
23
+
24
+ class FindCliError extends Error {
25
+ constructor(message: string) {
26
+ super(message);
27
+ this.name = "FindCliError";
28
+ }
29
+ }
30
+
31
+ type FindCliContext = {
32
+ convex: ConvexHttpClient;
33
+ args: string[];
34
+ jsonOutput: boolean;
35
+ sessionToken?: string;
36
+ enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
37
+ };
38
+
39
+ const api = {
40
+ saveFind: makeFunctionReference<"action">(
41
+ "functions/workspaceArtifacts:saveFind",
42
+ ),
43
+ updateFind: makeFunctionReference<"action">(
44
+ "functions/workspaceArtifacts:updateFind",
45
+ ),
46
+ rename: makeFunctionReference<"mutation">(
47
+ "functions/workspaceArtifacts:rename",
48
+ ),
49
+ remove: makeFunctionReference<"mutation">(
50
+ "functions/workspaceArtifacts:remove",
51
+ ),
52
+ list: makeFunctionReference<"query">("functions/workspaceArtifacts:list"),
53
+ get: makeFunctionReference<"query">("functions/workspaceArtifacts:get"),
54
+ importArtifact: makeFunctionReference<"action">(
55
+ "functions/workspaceArtifacts:importArtifact",
56
+ ),
57
+ resolveCompanyList: makeFunctionReference<"action">(
58
+ "functions/companyLists:resolveCompanyList",
59
+ ),
60
+ };
61
+
62
+ /** createdFrom values that mark an artifact as a saved search / list. */
63
+ const LIST_CREATED_FROM = new Set([
64
+ "find_people",
65
+ "find_companies",
66
+ "find_jobs",
67
+ "mixed",
68
+ "watch_fire",
69
+ ]);
70
+
71
+ function shift(args: string[], expected: string): string {
72
+ try {
73
+ return shiftRaw(args, expected);
74
+ } catch (error) {
75
+ if (error instanceof CliArgError) throw new FindCliError(error.message);
76
+ throw error;
77
+ }
78
+ }
79
+
80
+ function assertNoUnknownFlags(args: readonly string[], command: string): void {
81
+ try {
82
+ assertNoUnknownFlagsRaw(args, command);
83
+ } catch (error) {
84
+ if (error instanceof CliArgError) throw new FindCliError(error.message);
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ type LeadsFindRange = { min?: number; max?: number };
90
+
91
+ type LeadsFindFlags = {
92
+ titles?: string[];
93
+ pastTitles?: string[];
94
+ personNames?: string[];
95
+ locations?: string[];
96
+ seniorities?: string[];
97
+ industries?: string[];
98
+ functions?: string[];
99
+ subFunctions?: string[];
100
+ companyDomain?: string;
101
+ companyDomains?: string[];
102
+ companyNames?: string[];
103
+ pastCompanyNames?: string[];
104
+ companyHeadquarters?: string[];
105
+ companyTypes?: string[];
106
+ universities?: string[];
107
+ skills?: string[];
108
+ languages?: string[];
109
+ headcount?: string[];
110
+ yearsInRole?: LeadsFindRange;
111
+ yearsAtCompany?: LeadsFindRange;
112
+ daysSinceJobChange?: LeadsFindRange;
113
+ foundedYears?: LeadsFindRange;
114
+ keywords?: string;
115
+ companyList?: string;
116
+ limit?: number;
117
+ pageToken?: string;
118
+ name?: string;
119
+ };
120
+
121
+ /** Parse `min:max` (either side optional; a bare number means min). */
122
+ function rangeFlag(args: string[], name: string): LeadsFindRange | undefined {
123
+ const raw = getFlag(args, name);
124
+ if (!raw) return undefined;
125
+ const [minRaw, maxRaw] = raw.includes(":") ? raw.split(":", 2) : [raw, ""];
126
+ const parsePart = (part: string, label: string): number | undefined => {
127
+ const trimmed = part.trim();
128
+ if (!trimmed) return undefined;
129
+ const parsed = Number(trimmed);
130
+ if (!Number.isInteger(parsed) || parsed < 0) {
131
+ throw new FindCliError(
132
+ `${name} ${label} must be a non-negative integer (use min:max, e.g. 2:10)`,
133
+ );
134
+ }
135
+ return parsed;
136
+ };
137
+ const min = parsePart(minRaw ?? "", "min");
138
+ const max = parsePart(maxRaw ?? "", "max");
139
+ if (min === undefined && max === undefined) {
140
+ throw new FindCliError(`${name} must be min:max, e.g. 2:10, 2: or :10`);
141
+ }
142
+ return { min, max };
143
+ }
144
+
145
+ function parseLeadsFindFlags(args: string[]): LeadsFindFlags {
146
+ const flags: LeadsFindFlags = {
147
+ titles: csvFlag(args, "--titles"),
148
+ pastTitles: csvFlag(args, "--past-titles"),
149
+ personNames: csvFlag(args, "--person-names"),
150
+ locations: csvFlag(args, "--locations"),
151
+ seniorities: csvFlag(args, "--seniorities"),
152
+ industries: csvFlag(args, "--industries"),
153
+ functions: csvFlag(args, "--functions"),
154
+ subFunctions: csvFlag(args, "--sub-functions"),
155
+ companyDomain: getFlag(args, "--company-domain"),
156
+ companyDomains: csvFlag(args, "--company-domains"),
157
+ companyNames: csvFlag(args, "--company-names"),
158
+ pastCompanyNames: csvFlag(args, "--past-company-names"),
159
+ companyHeadquarters: csvFlag(args, "--company-hq"),
160
+ companyTypes: csvFlag(args, "--company-types"),
161
+ universities: csvFlag(args, "--universities"),
162
+ skills: csvFlag(args, "--skills"),
163
+ languages: csvFlag(args, "--languages"),
164
+ headcount: csvFlag(args, "--headcount"),
165
+ yearsInRole: rangeFlag(args, "--years-in-role"),
166
+ yearsAtCompany: rangeFlag(args, "--years-at-company"),
167
+ daysSinceJobChange: rangeFlag(args, "--days-since-job-change"),
168
+ foundedYears: rangeFlag(args, "--founded-years"),
169
+ keywords: getFlag(args, "--keywords"),
170
+ companyList: getFlag(args, "--company-list"),
171
+ limit: parseLimit(args),
172
+ pageToken: getFlag(args, "--page-token"),
173
+ name: getFlag(args, "--save-as"),
174
+ };
175
+ assertNoUnknownFlags(args, "leads find");
176
+ const hasFilter = Object.entries(flags).some(
177
+ ([key, value]) =>
178
+ !["limit", "pageToken", "name"].includes(key) && value !== undefined,
179
+ );
180
+ if (!hasFilter) {
181
+ throw new FindCliError(
182
+ "dench leads find requires at least one of --titles, --locations, --company-domain, --keywords, --industries, --seniorities (or any other filter flag — run `dench leads help`)",
183
+ );
184
+ }
185
+ return flags;
186
+ }
187
+
188
+ export function assertLeadsFindArgs(args: string[]): void {
189
+ const copy = [...args];
190
+ hasFlag(copy, "--json");
191
+ const verb = copy[0];
192
+ if (verb !== "find") return;
193
+ parseLeadsFindFlags(copy.slice(1));
194
+ }
195
+
196
+ function csvFlag(args: string[], name: string): string[] | undefined {
197
+ const raw = getFlag(args, name);
198
+ if (!raw) return undefined;
199
+ return raw
200
+ .split(",")
201
+ .map((part) => part.trim())
202
+ .filter(Boolean);
203
+ }
204
+
205
+ function parseLimit(args: string[]): number | undefined {
206
+ const raw = getFlag(args, "--limit");
207
+ if (!raw) return undefined;
208
+ const parsed = Number(raw);
209
+ if (!Number.isInteger(parsed) || parsed < 1) {
210
+ throw new FindCliError("--limit must be a positive integer");
211
+ }
212
+ return Math.min(parsed, 5000);
213
+ }
214
+
215
+ function out(ctx: FindCliContext, value: unknown): void {
216
+ console.log(JSON.stringify(value, null, ctx.jsonOutput ? 2 : 0));
217
+ }
218
+
219
+ async function callQuery(
220
+ ctx: FindCliContext,
221
+ fn: Parameters<ConvexHttpClient["query"]>[0],
222
+ args: Record<string, unknown> = {},
223
+ ): Promise<unknown> {
224
+ return await ctx.convex.query(fn, {
225
+ ...args,
226
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
227
+ } as never);
228
+ }
229
+
230
+ async function callAction(
231
+ ctx: FindCliContext,
232
+ fn: Parameters<ConvexHttpClient["action"]>[0],
233
+ args: Record<string, unknown> = {},
234
+ ): Promise<unknown> {
235
+ return await ctx.convex.action(fn, {
236
+ ...args,
237
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
238
+ } as never);
239
+ }
240
+
241
+ function asArray(value: unknown): unknown[] {
242
+ return Array.isArray(value) ? value : [];
243
+ }
244
+
245
+ /* ─── Company-list filter (--company-list) ─── */
246
+
247
+ type ResolvedCompanyList = {
248
+ source: "artifact" | "view";
249
+ title: string;
250
+ domains: string[];
251
+ names: string[];
252
+ totalCompanies: number;
253
+ truncated: boolean;
254
+ };
255
+
256
+ /** Convex document ids are long lowercase alphanumeric strings. */
257
+ const COMPANY_LIST_ID_RE = /^[a-z0-9]{25,40}$/;
258
+
259
+ /**
260
+ * `--company-list` accepts a saved-search artifact id (from
261
+ * `dench companies find` / `dench lists`) or a Company saved-view name.
262
+ * Id-shaped values are tried as artifacts first; anything else (or an
263
+ * id that doesn't resolve) is looked up as a view name.
264
+ */
265
+ async function resolveCompanyListFlag(
266
+ ctx: FindCliContext,
267
+ value: string,
268
+ ): Promise<ResolvedCompanyList> {
269
+ const trimmed = value.trim();
270
+ if (!trimmed) {
271
+ throw new FindCliError(
272
+ "--company-list requires a list artifact id or a Company view name",
273
+ );
274
+ }
275
+ if (COMPANY_LIST_ID_RE.test(trimmed)) {
276
+ try {
277
+ return (await callAction(ctx, api.resolveCompanyList, {
278
+ artifactId: trimmed,
279
+ })) as ResolvedCompanyList;
280
+ } catch {
281
+ // Not an artifact (or not ours) — fall through to view-name lookup.
282
+ }
283
+ }
284
+ try {
285
+ return (await callAction(ctx, api.resolveCompanyList, {
286
+ viewName: trimmed,
287
+ })) as ResolvedCompanyList;
288
+ } catch (error) {
289
+ throw new FindCliError(
290
+ error instanceof Error ? error.message : String(error),
291
+ );
292
+ }
293
+ }
294
+
295
+ /** Saved-filter entry: the reference (re-resolved on hydrate) + title. */
296
+ function companyListFilterEntry(
297
+ ref: string,
298
+ resolved: ResolvedCompanyList,
299
+ ): Record<string, unknown> {
300
+ return resolved.source === "artifact"
301
+ ? { artifactId: ref.trim(), title: resolved.title }
302
+ : { viewName: ref.trim(), title: resolved.title };
303
+ }
304
+
305
+ /** Output block mirroring /api/workspace/find's `companyList` response. */
306
+ function companyListInfo(resolved: ResolvedCompanyList) {
307
+ return {
308
+ source: resolved.source,
309
+ title: resolved.title,
310
+ companiesUsed: resolved.domains.length + resolved.names.length,
311
+ totalCompanies: resolved.totalCompanies,
312
+ truncated: resolved.truncated,
313
+ };
314
+ }
315
+
316
+ function mergeUnique(
317
+ base: string[] | undefined,
318
+ extra: string[] | undefined,
319
+ ): string[] | undefined {
320
+ const merged = [...new Set([...(base ?? []), ...(extra ?? [])])];
321
+ return merged.length > 0 ? merged : undefined;
322
+ }
323
+
324
+ function findTitle(
325
+ kind: "people" | "companies" | "jobs",
326
+ parts: string[],
327
+ ): string {
328
+ const first = parts.find((part) => part.length > 0);
329
+ if (kind === "people") return first ? `People: ${first}` : "People search";
330
+ if (kind === "companies") {
331
+ return first ? `Companies: ${first}` : "Company search";
332
+ }
333
+ return first ? `Jobs: ${first}` : "Job search";
334
+ }
335
+
336
+ async function persist(
337
+ ctx: FindCliContext,
338
+ args: {
339
+ createdFrom: "find_people" | "find_companies" | "find_jobs";
340
+ title: string;
341
+ filters: Record<string, unknown>;
342
+ people?: unknown[];
343
+ companies?: unknown[];
344
+ jobs?: unknown[];
345
+ },
346
+ ): Promise<unknown> {
347
+ return await callAction(ctx, api.saveFind, args);
348
+ }
349
+
350
+ export function leadsHelp(): void {
351
+ console.log(`Dench leads
352
+
353
+ Usage:
354
+ dench leads find --titles "VP Sales" --locations "San Francisco" [--company-domain acme.com] [--save-as "Q3 targets"] [--limit 500] [--page-token <token>] [--json]
355
+ dench leads find --keywords "fintech" [--industries SaaS] [--seniorities director,vp] [--json]
356
+ dench leads find --company-list <artifactId|"Company view name"> --titles "VP Sales" [--json]
357
+ dench leads filters [--filter titles|locations|company-hq|universities|skills|keywords|seniorities|functions|sub-functions|industries|headcount|company-types|languages] [--search "query"] [--json]
358
+
359
+ Filter flags (all comma-separated lists unless noted):
360
+ People: --titles --past-titles --person-names --seniorities --functions
361
+ --sub-functions --languages --universities --skills --locations
362
+ Company: --company-names --past-company-names --company-domain (single)
363
+ --company-domains --company-hq --company-types --industries
364
+ --headcount (buckets like 11-50,51-200)
365
+ List: --company-list <artifactId|view name> — filter by a saved
366
+ company list (a \`dench companies find\` artifact or a Company
367
+ saved view). Resolves to the list's CURRENT companies on each
368
+ run; lists up to ~1000 companies are searched in one go.
369
+ Ranges: --years-in-role --years-at-company --days-since-job-change
370
+ --founded-years (min:max, e.g. 2:10, 2: or :10)
371
+ Query: --keywords "fintech, AI"
372
+
373
+ \`dench leads filters\` prints the accepted values: enum filters (seniorities,
374
+ functions, industries, headcount, company-types, languages) expect those
375
+ exact values; the rest are free text with curated suggestions.
376
+
377
+ Finds people through Dench search and writes a workspace list artifact.
378
+ Pass --save-as to name the list (otherwise a title is generated).
379
+ Manage lists with \`dench lists\`; import with \`dench artifacts import <id> --to lead\`.
380
+ Typical chain: dench companies find … --json → grab artifactId →
381
+ dench leads find --company-list <artifactId> --titles "VP Sales".
382
+ `);
383
+ }
384
+
385
+ /* ─── dench leads filters ─── */
386
+
387
+ /** kebab-case flag spellings accepted by `--filter` alongside catalog keys. */
388
+ const FILTER_KEY_ALIASES: Record<string, string> = {
389
+ "past-titles": "pastTitles",
390
+ "company-hq": "companyHeadquarters",
391
+ "company-types": "companyTypes",
392
+ "sub-functions": "subFunctions",
393
+ };
394
+
395
+ /**
396
+ * Print the Lead Finder filter-option catalogs (offline; bundled data).
397
+ * Enum filters list exact gateway vocabularies; suggestion filters list
398
+ * curated starting points — custom free text always works for those.
399
+ */
400
+ export function runFindFiltersCommand(opts: {
401
+ args: string[];
402
+ jsonOutput?: boolean;
403
+ }): void {
404
+ const args = [...opts.args];
405
+ const jsonOutput = opts.jsonOutput === true || hasFlag(args, "--json");
406
+ const filterRaw = getFlag(args, "--filter");
407
+ const search = getFlag(args, "--search")?.trim().toLowerCase();
408
+ assertNoUnknownFlags(args, "leads filters");
409
+
410
+ const resolveKey = (raw: string): string => {
411
+ const aliased = FILTER_KEY_ALIASES[raw.toLowerCase()];
412
+ if (aliased) return aliased;
413
+ const match = Object.keys(FIND_FILTER_OPTION_CATALOG).find(
414
+ (key) => key.toLowerCase() === raw.toLowerCase(),
415
+ );
416
+ if (match) return match;
417
+ throw new FindCliError(
418
+ `Unknown filter: ${raw}. Valid filters: ${[
419
+ ...Object.keys(FIND_FILTER_OPTION_CATALOG),
420
+ ...Object.keys(FILTER_KEY_ALIASES),
421
+ ].join(", ")}`,
422
+ );
423
+ };
424
+
425
+ const keys = filterRaw
426
+ ? [resolveKey(filterRaw)]
427
+ : Object.keys(FIND_FILTER_OPTION_CATALOG);
428
+ const filters: Record<string, unknown> = {};
429
+ for (const key of keys) {
430
+ const entry = FIND_FILTER_OPTION_CATALOG[key];
431
+ const groups = search
432
+ ? entry.groups
433
+ .map((group) => ({
434
+ group: group.group,
435
+ values: group.values.filter((value) =>
436
+ value.toLowerCase().includes(search),
437
+ ),
438
+ }))
439
+ .filter((group) => group.values.length > 0)
440
+ : entry.groups;
441
+ filters[key] = { label: entry.label, kind: entry.kind, groups };
442
+ }
443
+ console.log(
444
+ JSON.stringify(
445
+ {
446
+ note: "kind=enum values are exact gateway vocabularies; kind=suggestion values are curated starting points and custom free text is accepted.",
447
+ filters,
448
+ },
449
+ null,
450
+ jsonOutput ? 2 : 0,
451
+ ),
452
+ );
453
+ }
454
+
455
+ export function companiesHelp(): void {
456
+ console.log(`Dench companies
457
+
458
+ Usage:
459
+ dench companies find --name "Acme" [--domain acme.com] [--keywords fintech] [--industries SaaS] [--locations "San Francisco"] [--linkedin https://linkedin.com/company/acme] [--save-as "Target accounts"] [--limit 500] [--page-token <token>] [--json]
460
+
461
+ Finds companies through Dench search and writes a workspace list artifact.
462
+ Pass --save-as to name the list (otherwise a title is generated).
463
+ Manage lists with \`dench lists\`; import with \`dench artifacts import <id> --to company\`.
464
+ The artifact id doubles as a company list: pass it to
465
+ \`dench leads find --company-list <id>\` or \`dench jobs find --company-list <id>\`
466
+ to search people or job postings at exactly these companies.
467
+ `);
468
+ }
469
+
470
+ export function jobsHelp(): void {
471
+ console.log(`Dench jobs
472
+
473
+ Usage:
474
+ dench jobs find --titles "Account Executive" --locations "San Francisco" [--company-domain acme.com] [--keywords remote] [--save-as "AE openings"] [--limit 500] [--page-token <token>] [--json]
475
+ dench jobs find --company-list <artifactId|"Company view name"> [--titles "Account Executive"] [--posted-after 2026-08-01] [--json]
476
+
477
+ Company filters: --company (name), --company-domain (single),
478
+ --company-domains (comma-separated), or --company-list (a saved company
479
+ list: a \`dench companies find\` artifact id or a Company saved-view name —
480
+ resolved to its current companies on each run).
481
+
482
+ Finds job postings through Dench search and writes a workspace list artifact.
483
+ Pass --save-as to name the list (otherwise a title is generated).
484
+ Manage lists with \`dench lists\`; import with \`dench artifacts import <id> --to job_posting\`.
485
+ `);
486
+ }
487
+
488
+ export function workspaceArtifactsHelp(): void {
489
+ console.log(`Dench artifacts
490
+
491
+ Usage:
492
+ dench artifacts [--limit 25] [--json]
493
+ dench artifacts list [--limit 25] [--json]
494
+ dench artifacts show <id> [--json]
495
+ dench artifacts import <id> --to lead|company|job_posting [--view "Q3 outbound"] [--pin] [--json]
496
+
497
+ \`list\` / \`show\` / \`import\` operate on find results (lead lists and job
498
+ searches). Bare \`dench artifacts\` still lists agent suggested-work artifacts.
499
+ Rename/delete saved searches with \`dench lists rename|delete\`.
500
+ `);
501
+ }
502
+
503
+ export function listsHelp(): void {
504
+ console.log(`Dench lists
505
+
506
+ Usage:
507
+ dench lists [--limit 25] [--json]
508
+ dench lists show <id> [--json]
509
+ dench lists rename <id> "New name" [--json]
510
+ dench lists update <id> [--title "New name"] [--filters-json '{"titles":["CTO"]}'] [--json]
511
+ dench lists delete <id> [--json]
512
+
513
+ Lists are saved searches from Find (people / companies / job postings).
514
+ Create one with \`dench leads|companies|jobs find --save-as "Name"\`.
515
+ Delete removes the list, its exported CSV/JSONL files, and any linked watch.
516
+ CRM lists (saved views on Leads/Companies/Job Postings) are managed with
517
+ \`dench crm views list|get|create|rename|delete\`.
518
+ `);
519
+ }
520
+
521
+ /**
522
+ * `dench lists …` — full management of saved searches. Returns false
523
+ * only for `help` so the caller can print usage.
524
+ */
525
+ export async function runListsCommand(opts: {
526
+ convex: ConvexHttpClient;
527
+ args: string[];
528
+ jsonOutput?: boolean;
529
+ sessionToken?: string;
530
+ }): Promise<boolean> {
531
+ const args = [...opts.args];
532
+ const ctx: FindCliContext = {
533
+ convex: opts.convex,
534
+ args,
535
+ jsonOutput: opts.jsonOutput === true || hasFlag(args, "--json"),
536
+ sessionToken: opts.sessionToken,
537
+ };
538
+ const verb = args[0];
539
+ if (verb === "help" || verb === "--help") return false;
540
+ if (verb === undefined || verb === "list" || verb?.startsWith("--")) {
541
+ if (verb === "list") args.shift();
542
+ const limitRaw = getFlag(args, "--limit");
543
+ const limit = limitRaw ? Number(limitRaw) : 25;
544
+ assertNoUnknownFlags(args, "lists");
545
+ const rows = (await callQuery(ctx, api.list, {
546
+ kind: "table",
547
+ // Over-fetch: the query filters by kind only; saved searches are
548
+ // the table artifacts whose createdFrom is a find/watch source.
549
+ limit: Math.min(Math.max(limit * 2, limit), 200),
550
+ })) as Array<Record<string, unknown>>;
551
+ const lists = rows
552
+ .filter((row) => LIST_CREATED_FROM.has(String(row.createdFrom)))
553
+ .slice(0, limit)
554
+ .map((row) => ({
555
+ _id: row._id,
556
+ title: row.title,
557
+ createdFrom: row.createdFrom,
558
+ rowCount: row.rowCount,
559
+ createdAt: row.createdAt,
560
+ watchId: row.watchId,
561
+ }));
562
+ out(ctx, lists);
563
+ return true;
564
+ }
565
+ args.shift();
566
+ if (verb === "show") {
567
+ const artifactId = shift(args, "list id");
568
+ assertNoUnknownFlags(args, "lists show");
569
+ out(ctx, await callQuery(ctx, api.get, { artifactId }));
570
+ return true;
571
+ }
572
+ if (verb === "rename") {
573
+ const artifactId = shift(args, "list id");
574
+ const title = getFlag(args, "--title") ?? shift(args, "new name");
575
+ assertNoUnknownFlags(args, "lists rename");
576
+ out(
577
+ ctx,
578
+ await ctx.convex.mutation(api.rename, {
579
+ artifactId,
580
+ title,
581
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
582
+ } as never),
583
+ );
584
+ return true;
585
+ }
586
+ if (verb === "update") {
587
+ const artifactId = shift(args, "list id");
588
+ const title = getFlag(args, "--title");
589
+ const filtersJson = getFlag(args, "--filters-json");
590
+ assertNoUnknownFlags(args, "lists update");
591
+ let filters: unknown;
592
+ if (filtersJson !== undefined) {
593
+ try {
594
+ filters = JSON.parse(filtersJson);
595
+ } catch {
596
+ throw new FindCliError("--filters-json must be valid JSON");
597
+ }
598
+ }
599
+ if (title === undefined && filters === undefined) {
600
+ throw new FindCliError(
601
+ "dench lists update requires --title and/or --filters-json",
602
+ );
603
+ }
604
+ out(
605
+ ctx,
606
+ await callAction(ctx, api.updateFind, {
607
+ artifactId,
608
+ ...(title !== undefined ? { title } : {}),
609
+ ...(filters !== undefined ? { filters } : {}),
610
+ }),
611
+ );
612
+ return true;
613
+ }
614
+ if (verb === "delete") {
615
+ const artifactId = shift(args, "list id");
616
+ assertNoUnknownFlags(args, "lists delete");
617
+ out(
618
+ ctx,
619
+ await ctx.convex.mutation(api.remove, {
620
+ artifactId,
621
+ ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}),
622
+ } as never),
623
+ );
624
+ return true;
625
+ }
626
+ throw new FindCliError(
627
+ `Unknown lists verb: ${verb}. Try list, show, rename, update, delete.`,
628
+ );
629
+ }
630
+
631
+ export function assertJobsFindArgs(args: string[]): void {
632
+ const copy = [...args];
633
+ hasFlag(copy, "--json");
634
+ if (copy[0] !== "find") return;
635
+ if (
636
+ !copy.includes("--titles") &&
637
+ !copy.includes("--locations") &&
638
+ !copy.includes("--company-domain") &&
639
+ !copy.includes("--company-domains") &&
640
+ !copy.includes("--company") &&
641
+ !copy.includes("--company-list") &&
642
+ !copy.includes("--keywords")
643
+ ) {
644
+ throw new FindCliError(
645
+ "dench jobs find requires at least one of --titles, --locations, --company-domain, --company-domains, --company, --company-list, --keywords",
646
+ );
647
+ }
648
+ }
649
+
650
+ export function assertCompaniesFindArgs(args: string[]): void {
651
+ const copy = [...args];
652
+ hasFlag(copy, "--json");
653
+ if (copy[0] !== "find") return;
654
+ if (
655
+ !copy.includes("--domain") &&
656
+ !copy.includes("--name") &&
657
+ !copy.includes("--keywords") &&
658
+ !copy.includes("--industries") &&
659
+ !copy.includes("--locations") &&
660
+ !copy.includes("--linkedin") &&
661
+ !copy.includes("--headcount")
662
+ ) {
663
+ throw new FindCliError(
664
+ "dench companies find requires at least one of --name, --domain, --keywords, --industries, --locations, --linkedin, --headcount",
665
+ );
666
+ }
667
+ }
668
+
669
+ export async function runLeadsCommand(opts: {
670
+ convex: ConvexHttpClient;
671
+ args: string[];
672
+ jsonOutput?: boolean;
673
+ sessionToken?: string;
674
+ enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
675
+ }): Promise<void> {
676
+ const args = [...opts.args];
677
+ const jsonFromArgs = hasFlag(args, "--json");
678
+ const ctx: FindCliContext = {
679
+ convex: opts.convex,
680
+ args,
681
+ jsonOutput: opts.jsonOutput === true || jsonFromArgs,
682
+ sessionToken: opts.sessionToken,
683
+ enrichmentGateway: opts.enrichmentGateway,
684
+ };
685
+ const verb = args.shift();
686
+ if (!verb || verb === "help" || verb === "--help") {
687
+ leadsHelp();
688
+ return;
689
+ }
690
+ if (verb === "filters") {
691
+ runFindFiltersCommand({ args, jsonOutput: ctx.jsonOutput });
692
+ return;
693
+ }
694
+ if (verb !== "find") {
695
+ throw new FindCliError(`Unknown leads verb: ${verb}`);
696
+ }
697
+ const { name: listName, ...flags } = parseLeadsFindFlags(args);
698
+ // Resolve --company-list fresh per search so the filter tracks the
699
+ // list's CURRENT companies (domains exact, name fallback for rows
700
+ // without a domain).
701
+ const companyList = flags.companyList
702
+ ? await resolveCompanyListFlag(ctx, flags.companyList)
703
+ : undefined;
704
+ try {
705
+ const result = await searchPeople(
706
+ {
707
+ titles: flags.titles,
708
+ pastTitles: flags.pastTitles,
709
+ personNames: flags.personNames,
710
+ locations: flags.locations,
711
+ seniorities: flags.seniorities,
712
+ jobFunctions: flags.functions,
713
+ subFunctions: flags.subFunctions,
714
+ industries: flags.industries,
715
+ organizationDomains: flags.companyDomain
716
+ ? [flags.companyDomain]
717
+ : undefined,
718
+ companyDomains: mergeUnique(flags.companyDomains, companyList?.domains),
719
+ companyNames: mergeUnique(flags.companyNames, companyList?.names),
720
+ pastCompanyNames: flags.pastCompanyNames,
721
+ companyHeadquarters: flags.companyHeadquarters,
722
+ companyTypes: flags.companyTypes,
723
+ universities: flags.universities,
724
+ skills: flags.skills,
725
+ languages: flags.languages,
726
+ headcountRanges: flags.headcount,
727
+ yearsInRole: flags.yearsInRole,
728
+ yearsAtCompany: flags.yearsAtCompany,
729
+ daysSinceJobChange: flags.daysSinceJobChange,
730
+ foundedYears: flags.foundedYears,
731
+ keywords: flags.keywords,
732
+ limit: flags.limit,
733
+ pageToken: flags.pageToken,
734
+ },
735
+ ctx.enrichmentGateway,
736
+ );
737
+ const people = asArray(result.people);
738
+ // Persist every set filter so the saved list hydrates the full rail.
739
+ const savedFilters: Record<string, unknown> = {};
740
+ for (const [key, value] of Object.entries(flags)) {
741
+ if (key === "pageToken" || value === undefined) continue;
742
+ savedFilters[key] = value;
743
+ }
744
+ if (flags.companyList && companyList) {
745
+ // Store the reference (not the resolved domains) so re-running the
746
+ // saved search picks up the list's contents at that time.
747
+ savedFilters.companyList = companyListFilterEntry(
748
+ flags.companyList,
749
+ companyList,
750
+ );
751
+ }
752
+ const artifact = await persist(ctx, {
753
+ createdFrom: "find_people",
754
+ title:
755
+ listName ??
756
+ findTitle("people", [
757
+ flags.titles?.[0] ?? "",
758
+ flags.keywords ?? "",
759
+ flags.companyDomain ?? "",
760
+ companyList?.title ?? "",
761
+ ]),
762
+ filters: savedFilters,
763
+ people,
764
+ });
765
+ // metadata carries total / nextPageToken — pass the token back via
766
+ // --page-token to continue where this page stopped.
767
+ out(ctx, {
768
+ ...(artifact as Record<string, unknown>),
769
+ metadata: result.metadata,
770
+ ...(companyList ? { companyList: companyListInfo(companyList) } : {}),
771
+ });
772
+ } catch (error) {
773
+ throw new FindCliError(
774
+ error instanceof EnrichmentGatewayError || error instanceof Error
775
+ ? error.message
776
+ : String(error),
777
+ );
778
+ }
779
+ }
780
+
781
+ export async function runJobsCommand(opts: {
782
+ convex: ConvexHttpClient;
783
+ args: string[];
784
+ jsonOutput?: boolean;
785
+ sessionToken?: string;
786
+ enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
787
+ }): Promise<void> {
788
+ const args = [...opts.args];
789
+ const jsonFromArgs = hasFlag(args, "--json");
790
+ const ctx: FindCliContext = {
791
+ convex: opts.convex,
792
+ args,
793
+ jsonOutput: opts.jsonOutput === true || jsonFromArgs,
794
+ sessionToken: opts.sessionToken,
795
+ enrichmentGateway: opts.enrichmentGateway,
796
+ };
797
+ const verb = args.shift();
798
+ if (!verb || verb === "help" || verb === "--help") {
799
+ jobsHelp();
800
+ return;
801
+ }
802
+ if (verb !== "find") {
803
+ throw new FindCliError(`Unknown jobs verb: ${verb}`);
804
+ }
805
+ const titles = csvFlag(args, "--titles");
806
+ const locations = csvFlag(args, "--locations");
807
+ const companyDomain = getFlag(args, "--company-domain");
808
+ const companyDomains = csvFlag(args, "--company-domains");
809
+ const companyName = getFlag(args, "--company");
810
+ const keywords = getFlag(args, "--keywords");
811
+ const postedAfter = getFlag(args, "--posted-after");
812
+ const companyListRef = getFlag(args, "--company-list");
813
+ const limit = parseLimit(args);
814
+ const pageToken = getFlag(args, "--page-token");
815
+ const listName = getFlag(args, "--save-as");
816
+ assertNoUnknownFlags(args, "jobs find");
817
+ if (
818
+ !titles &&
819
+ !locations &&
820
+ !companyDomain &&
821
+ !companyDomains &&
822
+ !companyName &&
823
+ !keywords &&
824
+ !companyListRef
825
+ ) {
826
+ throw new FindCliError(
827
+ "dench jobs find requires at least one of --titles, --locations, --company-domain, --company-domains, --company, --company-list, --keywords",
828
+ );
829
+ }
830
+ const companyList = companyListRef
831
+ ? await resolveCompanyListFlag(ctx, companyListRef)
832
+ : undefined;
833
+ try {
834
+ const result = await searchJobs(
835
+ {
836
+ titles,
837
+ locations,
838
+ companyDomain,
839
+ companyDomains: mergeUnique(companyDomains, companyList?.domains),
840
+ companyName,
841
+ companyNames: companyList?.names.length ? companyList.names : undefined,
842
+ keywords,
843
+ postedAfter,
844
+ limit,
845
+ pageToken,
846
+ },
847
+ ctx.enrichmentGateway,
848
+ );
849
+ const jobs = asArray(result.jobs);
850
+ const artifact = await persist(ctx, {
851
+ createdFrom: "find_jobs",
852
+ title:
853
+ listName ??
854
+ findTitle("jobs", [
855
+ titles?.[0] ?? "",
856
+ companyName ?? "",
857
+ keywords ?? "",
858
+ companyList?.title ?? "",
859
+ ]),
860
+ filters: {
861
+ titles,
862
+ locations,
863
+ companyDomain,
864
+ companyDomains,
865
+ companyName,
866
+ keywords,
867
+ postedAfter,
868
+ limit,
869
+ ...(companyListRef && companyList
870
+ ? {
871
+ companyList: companyListFilterEntry(companyListRef, companyList),
872
+ }
873
+ : {}),
874
+ },
875
+ jobs,
876
+ });
877
+ out(ctx, {
878
+ ...(artifact as Record<string, unknown>),
879
+ metadata: result.metadata,
880
+ ...(companyList ? { companyList: companyListInfo(companyList) } : {}),
881
+ });
882
+ } catch (error) {
883
+ throw new FindCliError(
884
+ error instanceof EnrichmentGatewayError || error instanceof Error
885
+ ? error.message
886
+ : String(error),
887
+ );
888
+ }
889
+ }
890
+
891
+ export async function runCompaniesCommand(opts: {
892
+ convex: ConvexHttpClient;
893
+ args: string[];
894
+ jsonOutput?: boolean;
895
+ sessionToken?: string;
896
+ enrichmentGateway?: Pick<EnrichmentGatewayOptions, "apiKey" | "baseUrl">;
897
+ }): Promise<void> {
898
+ const args = [...opts.args];
899
+ const jsonFromArgs = hasFlag(args, "--json");
900
+ const ctx: FindCliContext = {
901
+ convex: opts.convex,
902
+ args,
903
+ jsonOutput: opts.jsonOutput === true || jsonFromArgs,
904
+ sessionToken: opts.sessionToken,
905
+ enrichmentGateway: opts.enrichmentGateway,
906
+ };
907
+ const verb = args.shift();
908
+ if (!verb || verb === "help" || verb === "--help") {
909
+ companiesHelp();
910
+ return;
911
+ }
912
+ if (verb !== "find") {
913
+ throw new FindCliError(`Unknown companies verb: ${verb}`);
914
+ }
915
+ const domain = getFlag(args, "--domain");
916
+ const name = getFlag(args, "--name");
917
+ const keywords = getFlag(args, "--keywords");
918
+ const industries = csvFlag(args, "--industries");
919
+ const locations = csvFlag(args, "--locations");
920
+ const linkedinUrl = getFlag(args, "--linkedin");
921
+ const headcountRanges = csvFlag(args, "--headcount");
922
+ const limit = parseLimit(args);
923
+ const pageToken = getFlag(args, "--page-token");
924
+ const listName = getFlag(args, "--save-as");
925
+ assertNoUnknownFlags(args, "companies find");
926
+ if (
927
+ !domain &&
928
+ !name &&
929
+ !keywords &&
930
+ !industries &&
931
+ !locations &&
932
+ !linkedinUrl &&
933
+ !headcountRanges
934
+ ) {
935
+ throw new FindCliError(
936
+ "dench companies find requires at least one of --name, --domain, --keywords, --industries, --locations, --linkedin, --headcount",
937
+ );
938
+ }
939
+ try {
940
+ const result = await searchCompanies(
941
+ {
942
+ domain,
943
+ name,
944
+ keywords,
945
+ industries,
946
+ locations,
947
+ linkedinUrl,
948
+ headcountRanges,
949
+ limit,
950
+ pageToken,
951
+ },
952
+ ctx.enrichmentGateway,
953
+ );
954
+ const artifact = await persist(ctx, {
955
+ createdFrom: "find_companies",
956
+ title:
957
+ listName ??
958
+ findTitle("companies", [name ?? "", domain ?? "", keywords ?? ""]),
959
+ filters: {
960
+ domain,
961
+ name,
962
+ keywords,
963
+ industries,
964
+ locations,
965
+ linkedinUrl,
966
+ headcountRanges,
967
+ limit,
968
+ },
969
+ companies: asArray(result.companies),
970
+ });
971
+ out(ctx, {
972
+ ...(artifact as Record<string, unknown>),
973
+ metadata: result.metadata,
974
+ });
975
+ } catch (error) {
976
+ throw new FindCliError(
977
+ error instanceof EnrichmentGatewayError || error instanceof Error
978
+ ? error.message
979
+ : String(error),
980
+ );
981
+ }
982
+ }
983
+
984
+ export async function runWorkspaceArtifactsCommand(opts: {
985
+ convex: ConvexHttpClient;
986
+ args: string[];
987
+ jsonOutput?: boolean;
988
+ sessionToken?: string;
989
+ }): Promise<boolean> {
990
+ const args = [...opts.args];
991
+ const verb = args[0];
992
+ if (verb !== "list" && verb !== "show" && verb !== "import") {
993
+ return false;
994
+ }
995
+ args.shift();
996
+ const ctx: FindCliContext = {
997
+ convex: opts.convex,
998
+ args,
999
+ jsonOutput: opts.jsonOutput === true || hasFlag(args, "--json"),
1000
+ sessionToken: opts.sessionToken,
1001
+ };
1002
+ if (verb === "list") {
1003
+ const limitRaw = getFlag(args, "--limit");
1004
+ const limit = limitRaw ? Number(limitRaw) : 25;
1005
+ assertNoUnknownFlags(args, "artifacts list");
1006
+ out(ctx, await callQuery(ctx, api.list, { limit }));
1007
+ return true;
1008
+ }
1009
+ if (verb === "show") {
1010
+ const artifactId = shift(args, "artifact id");
1011
+ assertNoUnknownFlags(args, "artifacts show");
1012
+ out(ctx, await callQuery(ctx, api.get, { artifactId }));
1013
+ return true;
1014
+ }
1015
+ const artifactId = shift(args, "artifact id");
1016
+ const to = getFlag(args, "--to");
1017
+ if (to !== "lead" && to !== "job_posting" && to !== "company") {
1018
+ throw new FindCliError(
1019
+ "dench artifacts import <id> --to lead|company|job_posting [--view ...] [--pin]",
1020
+ );
1021
+ }
1022
+ const viewName = getFlag(args, "--view");
1023
+ const pin = hasFlag(args, "--pin");
1024
+ assertNoUnknownFlags(args, "artifacts import");
1025
+ out(
1026
+ ctx,
1027
+ await callAction(ctx, api.importArtifact, {
1028
+ artifactId,
1029
+ to,
1030
+ viewName,
1031
+ pin,
1032
+ }),
1033
+ );
1034
+ return true;
1035
+ }