/** * `dench crm ` — CLI surface for the Convex CRM. * * All operations resolve via the unified Dench API key baked into the * sandbox (DENCH_API_KEY) or a locally-stored agent session token from * `dench login`. The dispatcher does not care which one — the parent * (`cli/dench.ts → getRuntime`) picks whichever auth is available and * forwards a single bearer through `sessionToken`. The server-side * `requireCrmAccess` (convex/lib/crm/access.ts) accepts either format. * * Talks to the Convex deployment URL provided by the active runtime * (the session's `convexUrl` or the sandbox's `CONVEX_URL` envvar). * * This is a thin command dispatcher; the heavy lifting lives in * convex/functions/crm/* and convex/lib/crm/*. * * The full surface mirrors the plan's "dench-cli CRM surface" section: * crm objects list / get / create / update / delete / rename * crm views list / get / entries / create / update / delete * crm fields list / create / update / delete / reorder * crm entries list / get / dossier / create / create-many / update / update-many / delete / bulk-delete * crm detections list / undo (detection verdicts + LinkedIn rows; the one rollback for detections, linkedin, email-tasks) * crm privacy status / hide / show (your own opt-out: hide YOUR emails with a record from the workspace) * crm cells get / set / set-many / append * crm query --where … --select … --sort … --limit … * crm aggregate --field … * crm batch --file ops.jsonl * crm statuses list / set * crm docs list / create / link / delete * crm actions list / run / runs */ import type { ConvexHttpClient } from "convex/browser"; import { makeFunctionReference } from "convex/server"; import { runAnalyticsCommand } from "./crm-analytics"; import { runActivityCommand, runBriefCommand, runCommentsCommand, runNotesCommand, runTasksCommand, runWatchCommand, } from "./crm-comments"; import { runContextCommand } from "./crm-context"; import { runDetectionsCommand } from "./crm-detections"; import { runPrivacyCommand } from "./crm-privacy"; import { assertNoUnknownFlags as assertNoUnknownFlagsRaw, CliArgError, getFlag, getFlagWithAliases, hasFlag, type ParsedLimit, parseJson as parseJsonRaw, parseLimit as parseLimitRaw, shift as shiftRaw, } from "./lib/cli-args"; import { serializeCompactCrmEntry } from "./lib/crm-compact-reads"; import { autoDetectInputField, detectEnrichmentCategory, type EnrichmentCategory, type EnrichmentColumnDef, extractEnrichmentValue, type FieldCandidate, findEnrichmentColumn, getRequiredFieldsForApolloPath, } from "./lib/crm-enrichment"; import { aviatoEnrichPerson, type EnrichCompanyBody, EnrichmentGatewayError, type EnrichmentGatewayOptions, type EnrichPersonBody, enrichCompany, enrichPerson, enrichProfiles, } from "./lib/enrichment-gateway"; import { closestMatch, levenshteinDistance } from "./lib/levenshtein"; import { publicErrorMessage } from "./lib/public-error"; import { boundWorkspaceReferences, type WorkspaceReference, withWorkspaceReferenceEnvelopeUrls, workspaceReferenceHref, } from "./lib/workspace-references"; type JsonRecord = Record; const CRM_BATCH_CHUNK_SIZE = 200; export type CrmCliContext = { convex: ConvexHttpClient; args: string[]; jsonOutput: boolean; /** * Bearer token sent with every Convex call so the server-side * `requireCrmAccess` helper can resolve the caller's organization * without a Convex Auth cookie. Two formats are accepted: * • `dch_agent_*` agent session minted by `dench login`. * • Unified Dench API key (`dench_pk_*`, `dench_live_*`, * `gw_sk_*`, etc.) baked into sandbox envVars as `DENCH_API_KEY`. * The server dispatches on prefix; both resolve to the same * organization context. */ sessionToken?: string; runId?: string; enrichmentGateway?: Pick; /** App origin used to build absolute entry deep links (e.g. https://www.dench.com). */ host?: string; /** Workspace slug for absolute entry deep links (e.g. dench). */ workspaceSlug?: string; }; /** * Canonical CRM entry deep links. * * Absolute (shareable): `https://…/?path=&entry=:` * Relative (in-app): `/?path=&entry=:` * * Never invent `?object=` or a bare `entry=` without the object prefix. * `view` / `hidden` are optional UI chrome — not part of the canonical link. */ export function buildCrmEntryDeepLink(args: { host?: string; workspaceSlug?: string; objectName: string; entryId: string; }): { href: string; url?: string } { const objectName = args.objectName.trim(); const entryId = args.entryId.trim(); // Keep a literal `:` between object and id (docs + in-app links); only // encode the segments themselves so Convex ids stay readable. const entryParam = `${encodeURIComponent(objectName)}:${encodeURIComponent(entryId)}`; const pathParam = encodeURIComponent(objectName); const href = `/?path=${pathParam}&entry=${entryParam}`; const slug = args.workspaceSlug?.trim(); const host = args.host?.trim(); if (!slug || !host) { return { href }; } const withProtocol = /^https?:\/\//.test(host) ? host : `https://${host}`; let base: string; try { base = new URL(withProtocol).origin; } catch { return { href }; } return { href, url: `${base}/${encodeURIComponent(slug)}?path=${pathParam}&entry=${entryParam}`, }; } function entryIdFromUnknown(value: unknown): string | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } const record = value as JsonRecord; for (const key of ["entryId", "id"]) { const candidate = record[key]; if (typeof candidate === "string" && candidate.trim()) { return candidate.trim(); } } return undefined; } /** * Canonical object name echoed by `entries.get` / `entries.create` / * `batch.apply`. The server resolves display labels, case, and plurals * (`lib/crm/resolveObject.ts`), so the name the caller typed is often NOT * the one the workspace URL resolver matches on — it compares object names * exactly. Always prefer this over the raw CLI argument. */ function canonicalObjectNameFromUnknown(value: unknown): string | undefined { if (!value || typeof value !== "object" || Array.isArray(value)) { return undefined; } const candidate = (value as JsonRecord).objectName; return typeof candidate === "string" && candidate.trim() ? candidate.trim() : undefined; } function withCrmEntryDeepLinks( ctx: CrmCliContext, fallbackObjectName: string, value: unknown, ): unknown { const entryId = entryIdFromUnknown(value); if (!entryId || !value || typeof value !== "object" || Array.isArray(value)) { return value; } // Older deployments don't echo `objectName`; the raw arg is the only // thing left, and it's correct whenever the caller used the canonical name. const objectName = canonicalObjectNameFromUnknown(value) ?? fallbackObjectName; const links = buildCrmEntryDeepLink({ host: ctx.host, workspaceSlug: ctx.workspaceSlug, objectName, entryId, }); const record = value as JsonRecord; return { ...record, // Alias `id` ↔ `entryId`: server responses expose `entryId`, but // agents overwhelmingly `jq .id` (the shape `entries list` returns). // A null there caused retry-loops that created duplicate rows. ...(typeof record.id === "string" && record.id.trim() ? {} : { id: entryId }), href: links.href, // `url` stays conditional on host+workspaceSlug being known — an // absolute link with a guessed origin is worse than none. ...(links.url ? { url: links.url } : {}), }; } // Adapt the shared helpers to throw CrmCliError so existing call sites // don't need to change their try/catch shape. export class CrmCliError extends Error {} function userFacingConvexErrorMessage(error: unknown): string | null { if (!error || typeof error !== "object") return null; const data = (error as { data?: unknown }).data; if (typeof data === "string" && data.trim().length > 0) { return data.trim(); } if (data && typeof data === "object" && !Array.isArray(data)) { const record = data as Record; for (const key of ["message", "error", "reason"]) { const value = record[key]; if (typeof value === "string" && value.trim().length > 0) { return value.trim(); } } } return null; } function normalizeRpcError(error: unknown): Error { if (error instanceof CrmCliError) return error; return new CrmCliError( publicErrorMessage( userFacingConvexErrorMessage(error) ?? (error instanceof Error ? error.message : String(error)), ), { cause: error }, ); } export function shift(args: string[], expected: string): string { try { return shiftRaw(args, expected); } catch (error) { if (error instanceof CliArgError) throw new CrmCliError(error.message, { cause: error }); throw error; } } /** * shift() for name-bearing positionals (object/field/group names). A * value starting with `--` is always a mis-ordered flag, never a real * name — `dench crm fields create lead --name "Fit Score"` used to * create a literal field named "--name" in production CRMs. Fail fast * with the correct usage instead of writing the flag as data. */ function shiftName(args: string[], expected: string, usage: string): string { const value = shift(args, expected); if (value.startsWith("--")) { const label = expected.charAt(0).toUpperCase() + expected.slice(1); throw new CrmCliError( `${label} cannot start with "--". The name is positional: ${usage}`, ); } return value; } /** * Parse `--limit` against a server-side cap and make any clamping LOUD. * The server clamps silently, which historically let agents dedupe / * count over a subset believing it was the full dataset. The warning * always goes to stderr so `--json` stdout stays valid JSON; callers * whose output is a JSON object should also surface `limitClamped`. */ function resolveLimitFlag( args: string[], opts: { defaultValue: number; cap: number; command: string; fullDatasetHint?: string; }, ): ParsedLimit { const raw = getFlag(args, "--limit"); let parsed: ParsedLimit; try { parsed = parseLimitRaw(raw, opts); } catch (error) { if (error instanceof CliArgError) throw new CrmCliError(error.message, { cause: error }); throw error; } if (parsed.clamped) { process.stderr.write( `warning: --limit ${parsed.requested} exceeds the ${opts.cap}-row cap for ` + `${opts.command}; returned rows are the newest ${parsed.effective}. ` + `${opts.fullDatasetHint ?? "Use 'dench crm export' for full datasets."}\n`, ); } return parsed; } /** * The personal assignee scope flags shared by every read that supports it * (`views entries`, `entries list`, `query`, `analytics compute`): * * --assigned-to me|unassigned| (alias: --mine) * --assignee-field which `user` field counts * * Forwarded verbatim as `assignedTo` / `assigneeField`; the server resolves * "me", validates the member and picks the field (owner role → Assignee → * name → first user field). Drains the flags so `assertNoUnknownFlags` * stays honest. */ export function resolveAssigneeScopeFlags(args: string[]): { assignedTo?: string; assigneeField?: string; } { const assignedToRaw = getFlagWithAliases(args, "--assigned-to", [ "--assignedTo", ]); const mine = hasFlag(args, "--mine"); const assigneeField = getFlagWithAliases(args, "--assignee-field", [ "--assigneeField", ]); if (mine && assignedToRaw !== undefined) { throw new CrmCliError( "--mine and --assigned-to are the same switch; pass one of them.", ); } const assignedTo = mine ? "me" : assignedToRaw?.trim(); if (assignedTo !== undefined && assignedTo === "") { throw new CrmCliError( "--assigned-to needs a value: me, unassigned, or a member userId / email.", ); } if (assigneeField !== undefined && assignedTo === undefined) { throw new CrmCliError( "--assignee-field only makes sense with --assigned-to (or --mine).", ); } return { ...(assignedTo !== undefined ? { assignedTo } : {}), ...(assigneeField?.trim() ? { assigneeField: assigneeField.trim() } : {}), }; } /** * Reject curl-style `--data @rows.json`. The CLI never reads files off * an `@` prefix — the literal string "@rows.json" would be parsed as * (invalid) JSON, or worse, silently written as a value. */ function rejectAtFileValue(raw: string | undefined, flag: string): void { if (raw !== undefined && raw.trimStart().startsWith("@")) { throw new CrmCliError( `${flag}: "@file" syntax is not supported. Use --file .`, ); } } function parseJson(value: string | undefined): unknown { try { return parseJsonRaw(value); } catch (error) { if (error instanceof CliArgError) throw new CrmCliError(error.message, { cause: error }); throw error; } } export function assertNoUnknownFlags( args: readonly string[], command: string, ): void { try { assertNoUnknownFlagsRaw(args, command); } catch (error) { if (error instanceof CliArgError) throw new CrmCliError(error.message, { cause: error }); throw error; } } // Pull `--data ` from args, accepting `--fields` as an alias because // the entries.list output exposes the data envelope under `fields` and the // rest of the surface uses `crm fields` to manage column schemas — both // priors push callers toward `--fields`. We accept either; everywhere // downstream still calls the parameter `data` to match the server-side API. function getDataFlag(args: string[]): string | undefined { return getFlagWithAliases(args, "--data", ["--fields"]); } function getUpdatesFlag(args: string[]): string | undefined { return getFlagWithAliases(args, "--updates", ["--data"]); } function isJsonRecord(value: unknown): value is JsonRecord { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } function requireNonEmptyJsonRecord(value: unknown, label: string): JsonRecord { if (!isJsonRecord(value) || Object.keys(value).length === 0) { throw new CrmCliError(`${label} must be a non-empty JSON object`); } return value; } async function readJsonArrayInput(opts: { args: string[]; command: string; inlineFlag: string; inlineValue: string | undefined; }): Promise { const filePath = getFlag(opts.args, "--file"); assertNoUnknownFlags(opts.args, opts.command); if (opts.inlineValue !== undefined && filePath) { throw new CrmCliError( `${opts.command} accepts either ${opts.inlineFlag} or --file, not both`, ); } if (opts.inlineValue === undefined && !filePath) { throw new CrmCliError( `${opts.command} requires ${opts.inlineFlag} '' or --file `, ); } rejectAtFileValue(opts.inlineValue, opts.inlineFlag); let raw: string; if (filePath) { const fs = await import("node:fs/promises"); raw = await fs.readFile(filePath, "utf-8"); } else { raw = opts.inlineValue ?? ""; } const parsed = parseJson(raw); if (!Array.isArray(parsed)) { throw new CrmCliError(`${opts.inlineFlag} must be a JSON array`); } if (parsed.length === 0) { throw new CrmCliError(`${opts.inlineFlag} must include at least one item`); } return parsed; } function parseCreateManyRows(items: unknown[]): JsonRecord[] { return items.map((item, index) => requireNonEmptyJsonRecord(item, `create-many item ${index + 1}`), ); } function parseDedupeFieldFlag(raw: string | undefined): string | undefined { if (raw !== undefined && !raw.trim()) { throw new CrmCliError("--dedupe-key must name a non-empty CRM field"); } return raw; } /** * Stable per-row dedupe key derived from a named column. Values are * normalized (String → trim → lowercase) so `" Jane@X.com"` and * `"jane@x.com"` collapse onto the same key. Throws when a row has no * usable value — a blank dedupe cell would silently merge unrelated rows. */ function dedupeKeyFromColumn(opts: { prefix: string; column: string; row: JsonRecord; rowIndex: number; // 0-based command: string; }): string { const raw = opts.row[opts.column]; if ( raw !== null && raw !== undefined && typeof raw !== "string" && (typeof raw !== "number" || !Number.isFinite(raw)) ) { throw new CrmCliError( `${opts.command}: row ${opts.rowIndex + 1} must have a string or finite number for --dedupe-key column "${opts.column}"`, ); } const normalized = raw === null || raw === undefined ? "" : String(raw).trim().toLowerCase(); if (!normalized) { throw new CrmCliError( `${opts.command}: row ${opts.rowIndex + 1} has no value for --dedupe-key column "${opts.column}"`, ); } return `${opts.prefix}:${normalized}`; } /** * Deterministic JSON stringification (recursively sorted object keys). * Used to derive content-hash dedupe keys for `crm import`: the same * source row must map to the same key on every run — the previous * implementation keyed on `Date.now()`, which made every re-run of an * import duplicate all rows (a 42-row CSV became 84 rows). */ export function stableJsonStringify(value: unknown): string { if (Array.isArray(value)) { return `[${value.map(stableJsonStringify).join(",")}]`; } if (value && typeof value === "object") { const record = value as JsonRecord; const entries = Object.keys(record) .sort() .map( (key) => `${JSON.stringify(key)}:${stableJsonStringify(record[key])}`, ); return `{${entries.join(",")}}`; } return JSON.stringify(value) ?? "null"; } /** Count batch.apply result statuses (created / already_existed / updated). */ export function countResultStatuses(results: unknown[]): { created: number; alreadyExisted: number; updated: number; } { let created = 0; let alreadyExisted = 0; let updated = 0; for (const result of results) { const status = isJsonRecord(result) ? result.status : undefined; if (status === "already_existed") alreadyExisted += 1; else if (status === "updated") updated += 1; else created += 1; } return { created, alreadyExisted, updated }; } function parseUpdateManyRows(items: unknown[]): Array<{ id: string; data: JsonRecord; }> { return items.map((item, index) => { if (!isJsonRecord(item) || typeof item.id !== "string" || !item.id) { throw new CrmCliError( `update-many item ${index + 1} must include a string id`, ); } return { id: item.id, data: requireNonEmptyJsonRecord( item.data, `update-many item ${index + 1} data`, ), }; }); } function parseCellSetManyRows(items: unknown[]): Array<{ entryId: string; field: string; value: unknown; }> { return items.map((item, index) => { if (!isJsonRecord(item)) { throw new CrmCliError( `cells set-many item ${index + 1} must be a JSON object`, ); } if (typeof item.entryId !== "string" || !item.entryId) { throw new CrmCliError( `cells set-many item ${index + 1} must include a string entryId`, ); } if (typeof item.field !== "string" || !item.field) { throw new CrmCliError( `cells set-many item ${index + 1} must include a string field`, ); } return { entryId: item.entryId, field: item.field, value: item.value ?? null, }; }); } export type CrmBatchOp = | { type: "entries.create"; objectName: string; data: JsonRecord; /** Stable per-row dedupe key (server upserts on conflict). */ seedKey?: string; /** Match existing CRM entries by this scalar field before writing. */ matchField?: string; /** 1-based source row for server-side error messages. */ rowIndex?: number; } | { type: "entries.update"; entryId: string; data: JsonRecord }; export type CrmOnConflictPolicy = "skip" | "update" | "error"; export const CRM_OBJECT_VISIBILITY_FLAG_VALUES = [ "workspace", "owner_only", "owner_plus_members", ] as const; type CrmObjectVisibilityFlag = (typeof CRM_OBJECT_VISIBILITY_FLAG_VALUES)[number]; /** `--visibility` on `crm objects update`. Undefined when the flag is absent. */ export function parseObjectVisibilityFlag( value: string | undefined, ): CrmObjectVisibilityFlag | undefined { if (value === undefined) return undefined; const normalized = value.trim().toLowerCase().replace(/-/g, "_"); if ( (CRM_OBJECT_VISIBILITY_FLAG_VALUES as readonly string[]).includes( normalized, ) ) { return normalized as CrmObjectVisibilityFlag; } throw new Error( `--visibility must be one of ${CRM_OBJECT_VISIBILITY_FLAG_VALUES.join(", ")} (got "${value}")`, ); } export function parseOnConflictFlag( raw: string | undefined, ): CrmOnConflictPolicy { const value = raw ?? "skip"; if (value === "skip" || value === "update" || value === "error") { return value; } throw new CrmCliError( `--on-conflict must be skip, update, or error (got "${raw}")`, ); } function chunkArray(items: T[], size: number): T[][] { const chunks: T[][] = []; for (let index = 0; index < items.length; index += size) { chunks.push(items.slice(index, index + size)); } return chunks; } function chunkIdempotencyKey( idempotencyKey: string | undefined, chunkIndex: number, ): string | undefined { return idempotencyKey ? `${idempotencyKey}:chunk-${chunkIndex + 1}` : undefined; } export async function applyBatchOpsInChunks( ctx: CrmCliContext, ops: CrmBatchOp[], idempotencyKey: string | undefined, allowUnknown = false, onConflict?: CrmOnConflictPolicy, withReferences = false, ): Promise<{ chunks: number; results: unknown[]; references: WorkspaceReference[]; }> { const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE); const results: unknown[] = []; const references: WorkspaceReference[] = []; for (const [chunkIndex, chunk] of chunks.entries()) { const chunkKey = chunkIdempotencyKey(idempotencyKey, chunkIndex); const batchResult = (await callMutation(ctx, api.batch.apply, { ops: chunk, ...(withReferences ? { withReferences: true } : {}), ...runContextArgs(ctx), ...(chunkKey ? { idempotencyKey: chunkKey } : {}), ...(allowUnknown ? { allowUnknown: true } : {}), ...(onConflict ? { onConflict } : {}), })) as | { results?: unknown[]; references?: WorkspaceReference[] } | null | undefined; references.push(...(batchResult?.references ?? [])); if (Array.isArray(batchResult?.results)) { results.push(...batchResult.results); } } return { chunks: chunks.length, results, references: boundWorkspaceReferences(references).references, }; } function runContextArgs(ctx: CrmCliContext): Record { return ctx.runId ? { runId: ctx.runId } : {}; } /** * Fetch the canonical field names for an object. Best-effort: returns * `null` (rather than throwing) on any failure so the caller falls back * to the server's own unknown-field validation instead of blocking a * write on a transient read error. */ async function fetchFieldNames( ctx: CrmCliContext, objectName: string, ): Promise { try { const fields = (await callQuery(ctx, api.fields.list, { objectName })) as | Array<{ name?: unknown }> | null | undefined; if (!Array.isArray(fields)) return null; const names = fields .map((field) => (typeof field?.name === "string" ? field.name : null)) .filter((name): name is string => Boolean(name)); return names.length > 0 ? names : null; } catch { return null; } } // levenshteinDistance now lives in cli/lib/levenshtein.ts (shared with // the `dench apps` dispatcher's "did you mean" suggestions). function suggestKnownField(name: string, validNames: string[]): string | null { const lower = name.toLowerCase(); const caseMatch = validNames.find((valid) => valid.toLowerCase() === lower); if (caseMatch) return caseMatch; let best: string | null = null; let bestDistance = Number.POSITIVE_INFINITY; for (const valid of validNames) { const distance = levenshteinDistance(lower, valid.toLowerCase()); if (distance < bestDistance) { bestDistance = distance; best = valid; } } const threshold = Math.max(2, Math.floor(name.length / 2)); return best !== null && bestDistance <= threshold ? best : null; } /** * Pre-flight check for `--data` keys against the object's real field * schema, so a typo'd field name (e.g. `Name` on a `Title`-shaped task, * or `Owner` instead of `Assignee`) fails fast with a "did you mean …" * hint instead of silently dropping the value. The server enforces the * same rule; this just gives a faster, friendlier local error. No-op * when `--allow-unknown` is set or the schema can't be read. */ async function assertKnownDataKeys( ctx: CrmCliContext, objectName: string, rows: Array>, allowUnknown: boolean, ): Promise { if (allowUnknown) return; const validNames = await fetchFieldNames(ctx, objectName); if (!validNames) return; const validSet = new Set(validNames); const unknown = new Set(); for (const row of rows) { for (const key of Object.keys(row)) { if (!validSet.has(key)) unknown.add(key); } } if (unknown.size === 0) return; const parts = Array.from(unknown).map((name) => { const suggestion = suggestKnownField(name, validNames); return suggestion ? `"${name}" (did you mean "${suggestion}"?)` : `"${name}"`; }); throw new CrmCliError( `Unknown field${unknown.size > 1 ? "s" : ""} for "${objectName}": ` + `${parts.join(", ")}. Valid fields: ${validNames.join(", ")}. ` + `Run \`dench crm fields list ${objectName}\` to check the schema, ` + `or pass --allow-unknown to write the raw value anyway.`, ); } export async function callQuery( ctx: CrmCliContext, fn: Parameters[0], args: Record = {}, ): Promise { try { return await ctx.convex.query(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } catch (error) { throw normalizeRpcError(error); } } /** Every parsed token must be accounted for before an external effect. */ function assertWriteArguments(ctx: CrmCliContext): void { assertNoUnknownFlags(ctx.args, "crm"); if (ctx.args.length) { const error = new CliArgError( `Unexpected argument: ${ctx.args[0]}. Run the command with --help for usage.`, ); throw new CrmCliError(error.message, { cause: error }); } } export async function callMutation( ctx: CrmCliContext, fn: Parameters[0], args: Record = {}, ): Promise { assertWriteArguments(ctx); try { return await ctx.convex.mutation(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } catch (error) { throw normalizeRpcError(error); } } export async function callAction( ctx: CrmCliContext, fn: Parameters[0], args: Record = {}, ): Promise { assertWriteArguments(ctx); try { return await ctx.convex.action(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } catch (error) { throw normalizeRpcError(error); } } async function suppressForwardedConvexLogs( fn: () => Promise, ): Promise { const originalLog = console.log; const originalInfo = console.info; const shouldSuppress = (args: unknown[]) => typeof args[0] === "string" && args[0].startsWith("[CONVEX "); console.log = (...args: Parameters) => { if (shouldSuppress(args)) return; originalLog(...args); }; console.info = (...args: Parameters) => { if (shouldSuppress(args)) return; originalInfo(...args); }; try { return await fn(); } finally { console.log = originalLog; console.info = originalInfo; } } export function out(ctx: CrmCliContext, value: unknown): void { if (ctx.jsonOutput) { console.log(JSON.stringify(value, null, 2)); return; } if (value === null || value === undefined) { console.log("(empty)"); return; } if (Array.isArray(value)) { if (value.length === 0) { console.log("(no rows)"); return; } console.log(JSON.stringify(value, null, 2)); return; } console.log(JSON.stringify(value, null, 2)); } /** Only known product envelopes carry navigation metadata. Cells, documents, * exports and arbitrary JSON values must pass through out() unchanged. */ function outWithReferences(ctx: CrmCliContext, value: unknown): void { out(ctx, withWorkspaceReferenceEnvelopeUrls(value, ctx)); } function getFieldEnrichmentFlag(args: string[]): | { category: EnrichmentCategory; key: string; apolloPath: string; inputFieldName: string; } | undefined { const category = parseCategoryFlag(getFlag(args, "--enrichment-category")); const key = getFlag(args, "--enrichment-key"); const apolloPath = getFlagWithAliases(args, "--response-path", [ "--apollo-path", ]); const inputFieldName = getFlag(args, "--input-field"); const sawAny = category !== undefined || key !== undefined || apolloPath !== undefined || inputFieldName !== undefined; if (!sawAny) return undefined; if (!category || !apolloPath || !inputFieldName) { throw new CrmCliError( "Enrichment fields require --enrichment-category people|company, --response-path , and --input-field .", ); } return { category, key: key ?? apolloPath, apolloPath, inputFieldName, }; } const api = { transaction: { begin: makeFunctionReference<"mutation">("functions/crm/transaction:begin"), addOp: makeFunctionReference<"mutation">("functions/crm/transaction:addOp"), commit: makeFunctionReference<"mutation">( "functions/crm/transaction:commit", ), abort: makeFunctionReference<"mutation">("functions/crm/transaction:abort"), inspect: makeFunctionReference<"query">( "functions/crm/transaction:inspect", ), }, reports: { generate: makeFunctionReference<"query">("functions/crm/reports:generate"), }, enrich: { requestCellEnrichment: makeFunctionReference<"mutation">( "functions/crm/enrich:requestCellEnrichment", ), applyCellEnrichmentResult: makeFunctionReference<"mutation">( "functions/crm/enrich:applyCellEnrichmentResult", ), requestObjectEnrichment: makeFunctionReference<"mutation">( "functions/crm/enrich:requestObjectEnrichment", ), }, objects: { list: makeFunctionReference<"query">("functions/crm/objects:list"), listCompact: makeFunctionReference<"query">( "functions/crm/objects:listCompact", ), get: makeFunctionReference<"query">("functions/crm/objects:get"), materializeView: makeFunctionReference<"action">( "functions/crm/objects:completeView", ), create: makeFunctionReference<"mutation">("functions/crm/objects:create"), update: makeFunctionReference<"mutation">("functions/crm/objects:update"), rename: makeFunctionReference<"mutation">("functions/crm/objects:rename"), remove: makeFunctionReference<"mutation">("functions/crm/objects:remove"), saveView: makeFunctionReference<"mutation">( "functions/crm/objects:saveView", ), renameView: makeFunctionReference<"mutation">( "functions/crm/objects:renameView", ), deleteView: makeFunctionReference<"mutation">( "functions/crm/objects:deleteView", ), setArchived: makeFunctionReference<"mutation">( "functions/crm/objects:setArchived", ), setViewPinned: makeFunctionReference<"mutation">( "functions/crm/objects:setViewPinned", ), }, sidebar: { describe: makeFunctionReference<"query">( "functions/workspaceNav:describeMySidebar", ), reposition: makeFunctionReference<"mutation">( "functions/workspaceNav:repositionMyNavItem", ), createGroup: makeFunctionReference<"mutation">( "functions/workspaceNav:createNavGroup", ), renameGroup: makeFunctionReference<"mutation">( "functions/workspaceNav:renameNavGroup", ), deleteGroup: makeFunctionReference<"mutation">( "functions/workspaceNav:deleteNavGroup", ), moveItemToGroup: makeFunctionReference<"mutation">( "functions/workspaceNav:moveNavItemToGroup", ), setRootPin: makeFunctionReference<"mutation">( "functions/workspaceNav:setNavRootPin", ), }, fields: { list: makeFunctionReference<"query">("functions/crm/fields:list"), listCompact: makeFunctionReference<"query">( "functions/crm/fields:listCompact", ), create: makeFunctionReference<"mutation">("functions/crm/fields:create"), update: makeFunctionReference<"mutation">("functions/crm/fields:update"), remove: makeFunctionReference<"mutation">("functions/crm/fields:remove"), reorder: makeFunctionReference<"mutation">("functions/crm/fields:reorder"), reorderEnumValues: makeFunctionReference<"mutation">( "functions/crm/fields:reorderEnumValues", ), addEnumValue: makeFunctionReference<"mutation">( "functions/crm/fields:addEnumValue", ), removeEnumValue: makeFunctionReference<"mutation">( "functions/crm/fields:removeEnumValue", ), setEnumColor: makeFunctionReference<"mutation">( "functions/crm/fields:setEnumColor", ), renameEnumValue: makeFunctionReference<"mutation">( "functions/crm/fields:renameEnumValue", ), }, entries: { list: makeFunctionReference<"query">("functions/crm/entries:list"), listWithReferences: makeFunctionReference<"query">( "functions/crm/entries:listWithReferences", ), listPage: makeFunctionReference<"query">("functions/crm/entries:listPage"), get: makeFunctionReference<"query">("functions/crm/entries:get"), create: makeFunctionReference<"mutation">("functions/crm/entries:create"), update: makeFunctionReference<"mutation">("functions/crm/entries:update"), remove: makeFunctionReference<"mutation">("functions/crm/entries:remove"), bulkDelete: makeFunctionReference<"mutation">( "functions/crm/entries:bulkDelete", ), }, history: { listForEntry: makeFunctionReference<"query">( "functions/crm/history:listForEntry", ), }, cells: { get: makeFunctionReference<"query">("functions/crm/cells:get"), set: makeFunctionReference<"mutation">("functions/crm/cells:set"), append: makeFunctionReference<"mutation">("functions/crm/cells:append"), }, query: { queryEntriesWithReferences: makeFunctionReference<"action">( "functions/crm/query:runQueryEntriesWithReferences", ), queryEntries: makeFunctionReference<"action">( "functions/crm/query:runQueryEntries", ), scanEntries: makeFunctionReference<"query">( "functions/crm/query:scanEntries", ), aggregate: makeFunctionReference<"query">("functions/crm/query:aggregate"), search: makeFunctionReference<"query">("functions/crm/query:search"), searchWithReferences: makeFunctionReference<"query">( "functions/crm/query:searchWithReferences", ), searchPage: makeFunctionReference<"query">( "functions/crm/query:searchPage", ), }, statuses: { list: makeFunctionReference<"query">("functions/crm/statuses:list"), upsertMany: makeFunctionReference<"mutation">( "functions/crm/statuses:upsertMany", ), }, batch: { apply: makeFunctionReference<"mutation">("functions/crm/batch:apply"), }, documents: { list: makeFunctionReference<"query">("functions/crm/documents:list"), create: makeFunctionReference<"mutation">("functions/crm/documents:create"), link: makeFunctionReference<"mutation">("functions/crm/documents:link"), remove: makeFunctionReference<"mutation">("functions/crm/documents:remove"), }, actions: { list: makeFunctionReference<"query">("functions/crm/actions:list"), run: makeFunctionReference<"action">("functions/crm/actions:run"), startActionRun: makeFunctionReference<"mutation">( "functions/crm/actions:startActionRun", ), define: makeFunctionReference<"mutation">( "functions/crm/actions:defineAction", ), remove: makeFunctionReference<"mutation">( "functions/crm/actions:deleteAction", ), }, members: { list: makeFunctionReference<"query">("functions/crm/members:list"), }, automations: { list: makeFunctionReference<"query">("functions/crm/automations:list"), setEnabled: makeFunctionReference<"mutation">( "functions/crm/automations:setEnabled", ), }, templates: { listAvailable: makeFunctionReference<"query">( "functions/crm/templates:listAvailable", ), install: makeFunctionReference<"mutation">( "functions/crm/templates:install", ), removeSampleData: makeFunctionReference<"mutation">( "functions/crm/templates:removeSampleData", ), }, }; /** These read commands have local, command-specific help (no runtime needed). */ export function isCrmReadHelpRequest(args: readonly string[]): boolean { const command = args.find((arg) => arg !== "--json"); return ( (command === "query" || command === "aggregate") && (args.includes("--help") || args.includes("-h")) ); } export async function runCrmCommand(opts: { convex: ConvexHttpClient; args: string[]; sessionToken?: string; enrichmentGateway?: Pick; host?: string; workspaceSlug?: string; }): Promise { const args = [...opts.args]; const jsonOutput = hasFlag(args, "--json"); const ctx: CrmCliContext = { convex: opts.convex, args, jsonOutput, sessionToken: opts.sessionToken, runId: process.env.DENCH_RUN_ID?.trim() || undefined, enrichmentGateway: opts.enrichmentGateway, host: opts.host, workspaceSlug: opts.workspaceSlug, }; const subcommand = args.shift(); if (!subcommand || subcommand === "help" || subcommand === "--help") { crmHelp(); return; } if (isCrmReadHelpRequest(opts.args)) { if (subcommand === "query") { console.log(`Usage: dench crm query [--with-references] [--where ''] [--select Name,Email] [--sort -Score] [--limit N] [--json] --page [--cursor ''] Return {page,isDone,continueCursor} in index order; omit --sort. --assigned-to me|unassigned| (or --mine) [--assignee-field ] Default limit: 100. Maximum: 1000 for a one-shot array, 100 for a cursor page. Continue cursor pages until isDone, including empty filtered pages. Example: dench crm query company --where '{"Status":"New"}' --select Name --limit 25 --json`); } else { console.log(`Usage: dench crm aggregate --field [--sum ] [--json] Count rows per value of --field. --sum also totals a number field within each bucket. Example: dench crm aggregate deal --field Stage --sum Amount --json`); } return; } switch (subcommand) { case "objects": return await runObjectsCommand(ctx); case "views": return await runViewsCommand(ctx); case "sidebar": return await runSidebarCommand(ctx); case "fields": return await runFieldsCommand(ctx); case "entries": return await runEntriesCommand(ctx); case "history": return await runHistoryCommand(ctx); case "comments": return await runCommentsCommand(ctx); case "activity": return await runActivityCommand(ctx); case "brief": return await runBriefCommand(ctx); case "context": return await runContextCommand(ctx); case "notes": return await runNotesCommand(ctx); case "watch": return await runWatchCommand(ctx); case "tasks": return await runTasksCommand(ctx); case "cells": return await runCellsCommand(ctx); case "query": return await runQueryCommand(ctx); case "sql": return await runSqlCommand(ctx); case "aggregate": return await runAggregateCommand(ctx); case "search": return await runSearchCommand(ctx); case "statuses": return await runStatusesCommand(ctx); case "batch": return await runBatchCommand(ctx); case "import": return await runImportCommand(ctx); case "export": return await runExportCommand(ctx); case "transaction": return await runTransactionCommand(ctx); case "people": return await runPeopleCommand(ctx); case "companies": return await runCompaniesCommand(ctx); case "enrich": return await runEnrichCommand(ctx); case "reports": return await runReportsCommand(ctx); case "analytics": return await runAnalyticsCommand(ctx); case "detections": return await runDetectionsCommand(ctx); case "privacy": return await runPrivacyCommand(ctx); case "docs": case "documents": return await runDocsCommand(ctx); case "actions": return await runActionsCommand(ctx); case "automations": return await runAutomationsCommand(ctx); case "templates": return await runTemplatesCommand(ctx); case "members": // Alias for `dench members` so the AI agent finds the roster // surface from inside the `dench crm` namespace it's primed // on. Delegates to the shared implementation in cli/members.ts // so payload formatting + flag handling stay in lockstep. return await runCrmMembersCommand(ctx); default: { const suggestion = closestMatch(subcommand, CRM_SUBCOMMANDS); throw new CrmCliError( `Unknown crm subcommand: ${subcommand}` + `${suggestion ? `. Did you mean "${suggestion}"?` : ""}` + " Run `dench crm help` for the full list.", ); } } } /** Every verb the `runCrmCommand` switch accepts (for "did you mean"). */ const CRM_SUBCOMMANDS = [ "objects", "views", "sidebar", "fields", "entries", "history", "comments", "activity", "brief", "context", "notes", "watch", "tasks", "cells", "query", "sql", "aggregate", "search", "statuses", "batch", "import", "export", "transaction", "people", "companies", "enrich", "reports", "analytics", "detections", "privacy", "docs", "documents", "actions", "automations", "templates", "members", "help", ] as const; /** * `dench crm automations …` — deterministic CRM rules (installed by * templates, e.g. "qualified prospect → opportunity"). These fire inside * entry writes automatically; this surface is for visibility and the * enable/disable kill switch. There is no create verb here — rules are * installed by `crm templates install` (or programmatically). */ async function runAutomationsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { assertNoUnknownFlags(ctx.args, "crm automations list"); out(ctx, await callQuery(ctx, api.automations.list, {})); return; } case "enable": case "disable": { const automationId = shift(ctx.args, "automation id"); assertNoUnknownFlags(ctx.args, `crm automations ${verb}`); out( ctx, await callMutation(ctx, api.automations.setEnabled, { automationId, enabled: verb === "enable", }), ); return; } default: throw new CrmCliError( `Unknown automations verb: ${verb ?? "(none)"}. Use list | enable | disable .`, ); } } /** * `dench crm templates …` — one-click CRM setups (objects + fields + * kanban statuses + pinned views + automations + sample rows tagged * "Sample"). Installs are idempotent and compose across templates. */ async function runTemplatesCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { assertNoUnknownFlags(ctx.args, "crm templates list"); out(ctx, await callQuery(ctx, api.templates.listAvailable, {})); return; } case "install": { const templateId = shift(ctx.args, "template id"); assertNoUnknownFlags(ctx.args, "crm templates install"); out(ctx, await callMutation(ctx, api.templates.install, { templateId })); return; } case "remove-sample-data": { const templateId = shift(ctx.args, "template id"); assertNoUnknownFlags(ctx.args, "crm templates remove-sample-data"); out( ctx, await callMutation(ctx, api.templates.removeSampleData, { templateId, }), ); return; } default: throw new CrmCliError( `Unknown templates verb: ${verb ?? "(none)"}. Use list | install | remove-sample-data .`, ); } } async function runCrmMembersCommand(ctx: CrmCliContext): Promise { const { runMembersCommand } = await import("./members"); // Re-prepend `--json` so the members dispatcher's own `hasFlag` // pass sees it again — we already drained it once when computing // `ctx.jsonOutput`, but the members CLI re-parses argv from scratch // (same shape as a top-level invocation). const args = ctx.jsonOutput ? ["--json", ...ctx.args] : [...ctx.args]; await runMembersCommand({ convex: ctx.convex, args, sessionToken: ctx.sessionToken, }); } /** * `dench crm sidebar …` — the workspace app rail's Library. * * The whole layout is WORKSPACE-SHARED: one tree of rows and named * groups for every member. Permissions split by operation — `move` (and * `crm objects archive` / `crm views archive`) are ADMIN-ONLY because * they change what everyone sees; `group …` operations and view pinning * are open to any member. `list` shows the visible rows in tree order * (rows inside a group carry `groupId`/`groupName`), the groups, what's * hidden and why, and whether YOU are an admin (`viewerIsAdmin`). */ async function runSidebarCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift() ?? "list"; switch (verb) { case "list": { assertNoUnknownFlags(ctx.args, "crm sidebar list"); out(ctx, await callQuery(ctx, api.sidebar.describe, {})); return; } case "move": { const navId = shift(ctx.args, "nav id"); const rawPosition = shift(ctx.args, "position"); assertNoUnknownFlags(ctx.args, "crm sidebar move"); let position: string | number; if (rawPosition === "top" || rawPosition === "bottom") { position = rawPosition; } else { const parsed = Number.parseInt(rawPosition, 10); if (!Number.isFinite(parsed) || parsed < 1) { throw new CrmCliError( `position must be "top", "bottom", or a positive integer, got ${rawPosition}`, ); } position = parsed; } out( ctx, await callMutation(ctx, api.sidebar.reposition, { navId, position }), ); return; } // Pin/unpin a Library row (or a whole group) on the Sidebar's home // level shortcut strip. Member-level, workspace-shared — like view // pinning. case "pin": case "unpin": { const navId = shift(ctx.args, "nav id"); assertNoUnknownFlags(ctx.args, `crm sidebar ${verb}`); out( ctx, await callMutation(ctx, api.sidebar.setRootPin, { navId, pinned: verb === "pin", }), ); return; } case "group": { const groupVerb = ctx.args.shift(); switch (groupVerb) { case "create": { const name = shiftName( ctx.args, "group name", 'dench crm sidebar group create ""', ); assertNoUnknownFlags(ctx.args, "crm sidebar group create"); out(ctx, await callMutation(ctx, api.sidebar.createGroup, { name })); return; } case "rename": { const groupId = shift(ctx.args, "group id"); const name = shift(ctx.args, "new name"); assertNoUnknownFlags(ctx.args, "crm sidebar group rename"); out( ctx, await callMutation(ctx, api.sidebar.renameGroup, { groupId, name, }), ); return; } case "delete": { const groupId = shift(ctx.args, "group id"); assertNoUnknownFlags(ctx.args, "crm sidebar group delete"); out( ctx, await callMutation(ctx, api.sidebar.deleteGroup, { groupId }), ); return; } case "add": { const navId = shift(ctx.args, "nav id"); const groupId = shift(ctx.args, "group id"); assertNoUnknownFlags(ctx.args, "crm sidebar group add"); out( ctx, await callMutation(ctx, api.sidebar.moveItemToGroup, { navId, groupId, }), ); return; } case "remove": { const navId = shift(ctx.args, "nav id"); assertNoUnknownFlags(ctx.args, "crm sidebar group remove"); out( ctx, await callMutation(ctx, api.sidebar.moveItemToGroup, { navId, groupId: null, }), ); return; } default: throw new CrmCliError( `Unknown crm sidebar group verb: ${groupVerb ?? "(none)"}. ` + "Use create|rename|delete|add|remove.", ); } } default: throw new CrmCliError(`Unknown crm sidebar verb: ${verb}`); } } function objectResultReferences(value: unknown) { const refs: WorkspaceReference[] = Array.isArray(value) ? value.flatMap((raw) => { if (!raw || typeof raw !== "object" || typeof raw.name !== "string") return []; const identity = { kind: "crm-object" as const, objectName: raw.name, ...(typeof raw._id === "string" ? { objectId: raw._id } : {}), }; return [ { ...identity, label: typeof raw.displayName === "string" && raw.displayName ? raw.displayName : raw.name, href: workspaceReferenceHref(identity), ...(typeof raw.icon === "string" ? { objectIcon: raw.icon } : {}), }, ]; }) : []; return boundWorkspaceReferences(refs); } async function runObjectsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const compact = hasFlag(ctx.args, "--compact"); const references = hasFlag(ctx.args, "--with-references"); assertNoUnknownFlags(ctx.args, "crm objects list"); const data = await callQuery( ctx, compact ? api.objects.listCompact : api.objects.list, {}, ); outWithReferences( ctx, references ? { data, ...objectResultReferences(data) } : data, ); return; } case "get": { const name = shift(ctx.args, "object name"); assertNoUnknownFlags(ctx.args, "crm objects get"); const data = await callQuery(ctx, api.objects.get, { name }); outWithReferences( ctx, data && typeof data === "object" ? { ...data, ...objectResultReferences([data]) } : data, ); return; } case "create": { const name = shiftName( ctx.args, "object name", 'dench crm objects create "" [--description …] [--icon …]', ); const description = getFlag(ctx.args, "--description"); const defaultView = getFlag(ctx.args, "--default-view") as any; const icon = getFlag(ctx.args, "--icon"); assertNoUnknownFlags(ctx.args, "crm objects create"); outWithReferences( ctx, await callMutation(ctx, api.objects.create, { name, description, defaultView, icon, }), ); return; } case "update": { const name = shift(ctx.args, "object name"); const description = getFlag(ctx.args, "--description"); const defaultView = getFlag(ctx.args, "--default-view") as any; const icon = getFlag(ctx.args, "--icon"); // Cosmetic relabels (UI only; canonical `name` is unchanged, so // protected objects like `people` can be shown as e.g. "Clients"). // Pass an empty string to clear back to the default label. const displayName = getFlag(ctx.args, "--display-name"); const displayNameSingular = getFlag(ctx.args, "--display-name-singular"); // Who may read the object's records. Admin-only on the server. const visibility = parseObjectVisibilityFlag( getFlag(ctx.args, "--visibility"), ); assertNoUnknownFlags(ctx.args, "crm objects update"); const updated = await callMutation(ctx, api.objects.update, { name, description, defaultView, icon, displayName, displayNameSingular, visibility, }); if (visibility !== undefined && visibility !== "workspace") { process.stderr.write( `note: ${name} records created before this change have no owner and ` + "are hidden from everyone until one is set; switch back to " + "workspace to see them again.\n", ); } out(ctx, updated); return; } case "rename": { const from = shift(ctx.args, "current name"); const to = shiftName( ctx.args, "new name", 'dench crm objects rename ""', ); assertNoUnknownFlags(ctx.args, "crm objects rename"); out(ctx, await callMutation(ctx, api.objects.rename, { from, to })); return; } case "delete": case "remove": { const name = shift(ctx.args, "object name"); assertNoUnknownFlags(ctx.args, "crm objects delete"); out(ctx, await callMutation(ctx, api.objects.remove, { name })); return; } // Archive is the sidebar's "remove from the rail", NOT a delete: the // table, its rows and every link to it keep working, the row just // moves into the rail's Archived flyout. Team-shared, and ADMIN-ONLY // (the server rejects non-admin members). case "archive": case "unarchive": { const name = shift(ctx.args, "object name"); assertNoUnknownFlags(ctx.args, `crm objects ${verb}`); out( ctx, await callMutation(ctx, api.objects.setArchived, { name, archived: verb === "archive", }), ); return; } default: throw new CrmCliError(`Unknown crm objects verb: ${verb ?? ""}`); } } type SavedViewForCli = Record & { name?: unknown; pinnedEntryIds?: unknown; filters?: unknown; sort?: unknown; }; type CrmObjectWithViews = { name?: string; savedViews?: SavedViewForCli[]; }; function savedViewsFromObject( objectName: string, object: unknown, ): SavedViewForCli[] { if (!object || typeof object !== "object") { throw new CrmCliError(`CRM object "${objectName}" not found`); } const savedViews = (object as CrmObjectWithViews).savedViews; return Array.isArray(savedViews) ? savedViews : []; } function readViewName(view: SavedViewForCli): string | null { return typeof view.name === "string" && view.name.trim() ? view.name.trim() : null; } function resolveSavedView( objectName: string, views: SavedViewForCli[], requestedName: string, ): SavedViewForCli { const trimmed = requestedName.trim(); const exact = views.find((view) => readViewName(view) === trimmed); if (exact) return exact; const lower = trimmed.toLowerCase(); const insensitiveMatches = views.filter( (view) => readViewName(view)?.toLowerCase() === lower, ); if (insensitiveMatches.length === 1) return insensitiveMatches[0]; if (insensitiveMatches.length > 1) { throw new CrmCliError( `Multiple views named like "${requestedName}" exist on ${objectName}; use the exact case-sensitive name.`, ); } const available = views.map(readViewName).filter(Boolean).join(", "); throw new CrmCliError( `View "${requestedName}" not found on ${objectName}${ available ? `. Available views: ${available}` : ". No saved views exist." }`, ); } function summarizeSavedView(view: SavedViewForCli): Record { const pinned = Array.isArray(view.pinnedEntryIds) ? view.pinnedEntryIds : undefined; return { ...view, pinnedCount: pinned?.length ?? 0, }; } const VIEW_TYPES = [ "table", "kanban", "calendar", "timeline", "gallery", "list", ] as const; type SortRuleForCli = { field: string; direction: "asc" | "desc" }; /** * Parse `--sort` for views. Accepts either the canonical JSON array * (`[{"field":"Score","direction":"desc"}]`) or the compact syntax the * rest of the CRM CLI uses (`-Score,Name` — leading `-` means desc). */ function parseViewSortFlag( raw: string | undefined, ): SortRuleForCli[] | undefined { if (raw === undefined) return undefined; const trimmed = raw.trim(); if (!trimmed) return undefined; if (trimmed.startsWith("[")) { const parsed = parseJson(trimmed); if (!Array.isArray(parsed)) { throw new CrmCliError("--sort must be a JSON array of sort rules"); } return parsed.map((rule, index) => { if ( !isJsonRecord(rule) || typeof rule.field !== "string" || !rule.field || (rule.direction !== "asc" && rule.direction !== "desc") ) { throw new CrmCliError( `--sort rule ${index + 1} must look like {"field":"Score","direction":"desc"}`, ); } return { field: rule.field, direction: rule.direction }; }); } return trimmed .split(",") .map((token) => token.trim()) .filter(Boolean) .map((token) => token.startsWith("-") ? { field: token.slice(1), direction: "desc" as const } : { field: token, direction: "asc" as const }, ); } /** * Turn the `--where '{"Status":"Hot"}'` equality-map sugar into a real * FilterGroup. The operator per rule is chosen from the object's field * schema (enum → is / is_any_of, boolean → is_true / is_false, number * → eq, date → on, relation → has_any, everything else → equals) so * the saved view round-trips through the savedViewValidator. */ async function buildFiltersFromWhere( ctx: CrmCliContext, objectName: string, where: JsonRecord, ): Promise { const fields = (await callQuery(ctx, api.fields.list, { objectName })) as | Array<{ name?: unknown; type?: unknown }> | null | undefined; const knownFields = Array.isArray(fields) ? fields.filter( (field): field is { name: string; type?: string } => typeof field?.name === "string", ) : []; const rules = Object.entries(where).map(([rawName, value], index) => { const field = knownFields.find((candidate) => candidate.name === rawName) ?? knownFields.find( (candidate) => candidate.name.toLowerCase() === rawName.toLowerCase(), ); if (!field && knownFields.length > 0) { const suggestion = suggestKnownField( rawName, knownFields.map((candidate) => candidate.name), ); throw new CrmCliError( `Unknown field "${rawName}" on ${objectName}` + `${suggestion ? ` (did you mean "${suggestion}"?)` : ""}. ` + `Valid fields: ${knownFields.map((candidate) => candidate.name).join(", ")}`, ); } const fieldName = field?.name ?? rawName; const type = field?.type; const id = `w${index + 1}`; if (type === "boolean") { const isFalsy = !value || value === "false" || value === "0" || value === "no" || value === "off"; return { id, field: fieldName, operator: isFalsy ? "is_false" : "is_true", }; } if (type === "enum") { return Array.isArray(value) ? { id, field: fieldName, operator: "is_any_of", value: value.map(String), } : { id, field: fieldName, operator: "is", value: String(value) }; } if (type === "number") { return { id, field: fieldName, operator: "eq", value: Number(value) }; } if (type === "date") { return { id, field: fieldName, operator: "on", value: String(value) }; } if (type === "relation") { return { id, field: fieldName, operator: "has_any", value: Array.isArray(value) ? value.map(String) : [String(value)], }; } return { id, field: fieldName, operator: "equals", value: String(value) }; }); return { id: "where", conjunction: "and", rules }; } /** * Shared flag parsing for `crm views create` / `crm views update`. * Returns only the keys the caller actually passed so `update` can * merge over the existing view without clobbering untouched keys. */ async function collectViewFlags( ctx: CrmCliContext, objectName: string, command: string, ): Promise { const filtersRaw = getFlag(ctx.args, "--filters"); const whereRaw = getFlag(ctx.args, "--where"); const sortRaw = getFlag(ctx.args, "--sort"); const columnsRaw = getFlag(ctx.args, "--columns"); const typeRaw = getFlag(ctx.args, "--type"); const settingsRaw = getFlag(ctx.args, "--settings"); const pinnedRaw = getFlag(ctx.args, "--pinned-entry-ids"); if (filtersRaw !== undefined && whereRaw !== undefined) { throw new CrmCliError( `${command} accepts either --filters (full FilterGroup JSON) or --where (equality map), not both`, ); } const patch: JsonRecord = {}; if (filtersRaw !== undefined) { const filters = parseJson(filtersRaw); if ( !isJsonRecord(filters) || typeof filters.conjunction !== "string" || !Array.isArray(filters.rules) ) { throw new CrmCliError( '--filters must be a FilterGroup JSON object: {"id":"g1","conjunction":"and","rules":[{"id":"r1","field":"Status","operator":"is","value":"Hot"}]}', ); } if (typeof filters.id !== "string" || !filters.id) filters.id = "g1"; patch.filters = filters; } if (whereRaw !== undefined) { const where = requireNonEmptyJsonRecord(parseJson(whereRaw), "--where"); patch.filters = await buildFiltersFromWhere(ctx, objectName, where); } const sort = parseViewSortFlag(sortRaw); if (sort !== undefined) patch.sort = sort; if (columnsRaw !== undefined) { patch.columns = columnsRaw .split(",") .map((column) => column.trim()) .filter(Boolean); } if (typeRaw !== undefined) { if (!(VIEW_TYPES as readonly string[]).includes(typeRaw)) { throw new CrmCliError( `--type must be one of: ${VIEW_TYPES.join(", ")} (got "${typeRaw}")`, ); } patch.view_type = typeRaw; } if (settingsRaw !== undefined) { patch.settings = requireNonEmptyJsonRecord( parseJson(settingsRaw), "--settings", ); } if (pinnedRaw !== undefined) { patch.pinnedEntryIds = pinnedRaw .split(",") .map((id) => id.trim()) .filter(Boolean); } return patch; } async function runViewsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const objectName = shift(ctx.args, "object name"); assertNoUnknownFlags(ctx.args, "crm views list"); const object = await callQuery(ctx, api.objects.get, { name: objectName, }); const views = savedViewsFromObject(objectName, object).map( summarizeSavedView, ); out(ctx, views); return; } case "get": { const objectName = shift(ctx.args, "object name"); const viewName = shift(ctx.args, "view name"); assertNoUnknownFlags(ctx.args, "crm views get"); const object = await callQuery(ctx, api.objects.get, { name: objectName, }); const view = resolveSavedView( objectName, savedViewsFromObject(objectName, object), viewName, ); out(ctx, summarizeSavedView(view)); return; } case "entries": { const objectName = shift(ctx.args, "object name"); const viewName = shift(ctx.args, "view name"); const page = Number.parseInt(getFlag(ctx.args, "--page") ?? "1", 10); const pageSize = Number.parseInt( getFlagWithAliases(ctx.args, "--page-size", ["--limit"]) ?? "100", 10, ); const withMeta = hasFlag(ctx.args, "--with-meta"); const after = getFlag(ctx.args, "--cursor"); const assigneeScopeArgs = resolveAssigneeScopeFlags(ctx.args); assertNoUnknownFlags(ctx.args, "crm views entries"); if (!Number.isFinite(page) || page < 1) { throw new CrmCliError(`--page must be a positive integer, got ${page}`); } if (!Number.isFinite(pageSize) || pageSize < 1) { throw new CrmCliError( `--page-size/--limit must be a positive integer, got ${pageSize}`, ); } const object = await callQuery(ctx, api.objects.get, { name: objectName, }); const view = resolveSavedView( objectName, savedViewsFromObject(objectName, object), viewName, ); const pinnedEntryIds = Array.isArray(view.pinnedEntryIds) ? view.pinnedEntryIds.filter( (id): id is string => typeof id === "string" && id.length > 0, ) : undefined; const materialized = (await suppressForwardedConvexLogs(() => callAction(ctx, api.objects.materializeView, { objectName, page, pageSize, after, filters: view.filters, sort: Array.isArray(view.sort) ? view.sort : undefined, pinnedEntryIds, includeReverseRelations: false, ...assigneeScopeArgs, }), )) as { entries?: unknown[]; totalCount?: number; page?: number; pageSize?: number; truncated?: boolean; continueCursor?: string; isDone?: boolean; startedAt?: number; completedAt?: number; assigneeScope?: unknown; } | null; if (!materialized) { throw new CrmCliError(`CRM object "${objectName}" not found`); } const entries = Array.isArray(materialized.entries) ? materialized.entries : []; // The bare-array output hides `truncated`; say so on stderr so a // clipped view is never mistaken for the full dataset (stdout stays // clean JSON). if (materialized.truncated === true) { const total = materialized.totalCount; const returned = typeof total === "number" && total > entries.length ? `${entries.length} of ${total} matching rows returned` : `${entries.length} rows returned; more may match`; process.stderr.write( `warning: view "${readViewName(view) ?? viewName}" on ${objectName} hit the server read budget; ${returned}. ` + `Use 'dench crm query ${objectName} --where …' or 'dench crm export' for the full set.\n`, ); } if (!withMeta) { out(ctx, entries); return; } out(ctx, { objectName, viewName: readViewName(view) ?? viewName, view: summarizeSavedView(view), totalCount: materialized.totalCount ?? entries.length, returnedCount: entries.length, page: materialized.page ?? page, pageSize: materialized.pageSize ?? pageSize, truncated: materialized.truncated ?? false, continueCursor: materialized.continueCursor, isDone: materialized.isDone, startedAt: materialized.startedAt, completedAt: materialized.completedAt, assigneeScope: materialized.assigneeScope ?? null, entries, }); return; } case "create": { const objectName = shift(ctx.args, "object name"); // View name is positional, but `--name` is accepted too because // that's the flag agents reach for first. const nameFlag = getFlag(ctx.args, "--name"); const viewName = nameFlag ?? (ctx.args[0] && !ctx.args[0].startsWith("--") ? shift(ctx.args, "view name") : undefined); if (!viewName?.trim()) { throw new CrmCliError( 'crm views create requires a view name: dench crm views create "" [--where …]', ); } const replace = hasFlag(ctx.args, "--replace"); const setActive = hasFlag(ctx.args, "--set-active"); const patch = await collectViewFlags(ctx, objectName, "crm views create"); assertNoUnknownFlags(ctx.args, "crm views create"); out( ctx, await callMutation(ctx, api.objects.saveView, { objectName, view: { name: viewName.trim(), ...patch }, replaceExisting: replace, setActive, }), ); return; } case "update": { const objectName = shift(ctx.args, "object name"); const viewName = shift(ctx.args, "view name"); const setActive = hasFlag(ctx.args, "--set-active"); const patch = await collectViewFlags(ctx, objectName, "crm views update"); assertNoUnknownFlags(ctx.args, "crm views update"); if (Object.keys(patch).length === 0 && !setActive) { throw new CrmCliError( "crm views update needs at least one change: --filters/--where, --sort, --columns, --type, --settings, --pinned-entry-ids, or --set-active", ); } // Merge over the existing view so untouched keys survive. The // existing (exact-cased) name is reused, which also makes a // case-insensitive argument update the right view. const object = await callQuery(ctx, api.objects.get, { name: objectName, }); const existing = resolveSavedView( objectName, savedViewsFromObject(objectName, object), viewName, ); out( ctx, await callMutation(ctx, api.objects.saveView, { objectName, view: { ...existing, ...patch, name: readViewName(existing) }, replaceExisting: true, setActive, }), ); return; } // Rename keeps the whole view definition (filters, pins, sidebar // slot) and just swaps the name — unlike `update`, which upserts by // name and so cannot change one. case "rename": { const objectName = shift(ctx.args, "object name"); const viewName = shift(ctx.args, "view name"); const newName = getFlag(ctx.args, "--to") ?? shiftName( ctx.args, "new name", 'dench crm views rename "" ""', ); assertNoUnknownFlags(ctx.args, "crm views rename"); if (!newName.trim()) { throw new CrmCliError( 'crm views rename requires the new name: dench crm views rename "" ""', ); } out( ctx, await callMutation(ctx, api.objects.renameView, { objectName, viewName, newName: newName.trim(), }), ); return; } case "delete": { const objectName = shift(ctx.args, "object name"); const viewName = shift(ctx.args, "view name"); assertNoUnknownFlags(ctx.args, "crm views delete"); out( ctx, await callMutation(ctx, api.objects.deleteView, { objectName, viewName, }), ); return; } // Only `pinned` views render as workspace sidebar rows. Pinning also // clears any archive stamp, since a pinned-but-archived view still // wouldn't show up. case "pin": case "unpin": { const objectName = shift(ctx.args, "object name"); const viewName = shift(ctx.args, "view name"); assertNoUnknownFlags(ctx.args, `crm views ${verb}`); out( ctx, await callMutation(ctx, api.objects.setViewPinned, { objectName, viewName, pinned: verb === "pin", }), ); return; } case "archive": case "unarchive": { const objectName = shift(ctx.args, "object name"); const viewName = shift(ctx.args, "view name"); assertNoUnknownFlags(ctx.args, `crm views ${verb}`); out( ctx, await callMutation(ctx, api.objects.setArchived, { name: objectName, viewName, archived: verb === "archive", }), ); return; } default: throw new CrmCliError(`Unknown crm views verb: ${verb ?? ""}`); } } function getFieldCalculationFlag(args: string[]) { for (const flag of ["--calculation", "--campaign"]) { const index = args.indexOf(flag); if (index < 0) continue; const value = args[index + 1]; if (!value || value.startsWith("--")) { throw new CrmCliError(`${flag} requires a value.`); } } const metric = getFlag(args, "--calculation"); const campaignId = getFlag(args, "--campaign"); const metrics = [ "email_opened", "email_clicked", "replied", "linkedin_connected", ] as const; if (metric === undefined) { if (campaignId !== undefined) { throw new CrmCliError("--campaign requires --calculation ."); } return undefined; } const selected = metrics.find((candidate) => candidate === metric); if (!selected) { throw new CrmCliError( `--calculation must be one of: ${metrics.join(", ")}.`, ); } if (campaignId !== undefined && !campaignId.trim()) { throw new CrmCliError("--campaign must be a multichannel campaign ID."); } return { kind: "campaign_activity" as const, metric: selected, ...(campaignId === undefined ? {} : { campaignId: campaignId.trim() }), }; } async function runFieldsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const objectName = shift(ctx.args, "object name"); const compact = hasFlag(ctx.args, "--compact"); assertNoUnknownFlags(ctx.args, "crm fields list"); out( ctx, await callQuery( ctx, compact ? api.fields.listCompact : api.fields.list, { objectName }, ), ); return; } case "create": { const objectName = shift(ctx.args, "object name"); const name = shiftName( ctx.args, "field name", 'dench crm fields create "" --type ', ); const calculation = getFieldCalculationFlag(ctx.args); const type = getFlag(ctx.args, "--type") ?? (calculation ? "boolean" : "text"); const required = hasFlag(ctx.args, "--required"); const description = getFlag(ctx.args, "--description"); const enumValues = parseJson(getFlag(ctx.args, "--enum-values")) as | string[] | undefined; const enumColors = parseJson(getFlag(ctx.args, "--enum-colors")) as | string[] | undefined; const enumMultiple = hasFlag(ctx.args, "--enum-multiple"); const relatedObjectName = getFlag(ctx.args, "--related-object"); const relationshipType = getFlag(ctx.args, "--relationship") as any; const indexed = hasFlag(ctx.args, "--indexed") ? true : hasFlag(ctx.args, "--no-indexed") ? false : undefined; const enrichment = getFieldEnrichmentFlag(ctx.args); assertNoUnknownFlags(ctx.args, "crm fields create"); if ( calculation && (type !== "boolean" || required || indexed || enrichment || enumValues !== undefined || enumColors !== undefined || enumMultiple || relatedObjectName !== undefined || relationshipType !== undefined) ) { throw new CrmCliError( "Campaign calculation columns are read-only booleans; --type must be boolean, without required, indexed, enrichment, enum or relation options.", ); } out( ctx, await callMutation(ctx, api.fields.create, { objectName, name, type, required, description, enumValues, enumColors, enumMultiple, relatedObjectName, relationshipType, indexed, enrichment, calculation, }), ); return; } case "update": { const objectName = shift(ctx.args, "object name"); const fieldName = shift(ctx.args, "field name"); const calculation = getFieldCalculationFlag(ctx.args); const clearCalculation = hasFlag(ctx.args, "--clear-calculation"); const description = getFlag(ctx.args, "--description"); const required = hasFlag(ctx.args, "--required") ? true : undefined; const enumValues = parseJson(getFlag(ctx.args, "--enum-values")) as | string[] | undefined; const indexed = hasFlag(ctx.args, "--indexed") ? true : hasFlag(ctx.args, "--no-indexed") ? false : undefined; const enrichment = getFieldEnrichmentFlag(ctx.args); const clearEnrichment = hasFlag(ctx.args, "--clear-enrichment"); assertNoUnknownFlags(ctx.args, "crm fields update"); if (calculation && clearCalculation) { throw new CrmCliError( "Choose --calculation or --clear-calculation, not both.", ); } if ( calculation && (required || indexed || enrichment || enumValues !== undefined) ) { throw new CrmCliError( "Campaign calculation columns are read-only; required, indexed, enrichment and enum options are not supported.", ); } out( ctx, await callMutation(ctx, api.fields.update, { objectName, fieldName, description, required, enumValues, indexed, enrichment, clearEnrichment, calculation, clearCalculation, }), ); return; } case "delete": case "remove": { const objectName = shift(ctx.args, "object name"); const fieldName = shift(ctx.args, "field name"); assertNoUnknownFlags(ctx.args, "crm fields delete"); out( ctx, await callMutation(ctx, api.fields.remove, { objectName, fieldName, }), ); return; } case "reorder": { const objectName = shift(ctx.args, "object name"); // Assert before consuming the rest as positional field names so a // typo'd flag fails loudly rather than getting silently filtered. assertNoUnknownFlags(ctx.args, "crm fields reorder"); const orderedFieldNames = ctx.args.splice(0); out( ctx, await callMutation(ctx, api.fields.reorder, { objectName, orderedFieldNames, }), ); return; } case "enum-reorder": { const objectName = shift(ctx.args, "object name"); const fieldName = shift(ctx.args, "field name"); // Same pattern as `reorder`: peel positional args after asserting // there are no stray flags so a typo'd `--limit` fails loud // instead of being silently consumed as an enum value. assertNoUnknownFlags(ctx.args, "crm fields enum-reorder"); const orderedValues = ctx.args.splice(0); if (orderedValues.length === 0) { throw new CrmCliError( "dench crm fields enum-reorder requires at least one value. " + 'Pass each enum value as a positional arg: `enum-reorder task Status "Done" "In Progress" "To Do"`', ); } out( ctx, await callMutation(ctx, api.fields.reorderEnumValues, { objectName, fieldName, orderedValues, }), ); return; } case "enum-add": { const objectName = shift(ctx.args, "object name"); const fieldName = shift(ctx.args, "field name"); const value = shift(ctx.args, "enum value"); const color = getFlag(ctx.args, "--color"); const positionStr = getFlag(ctx.args, "--position"); const position = positionStr !== undefined ? Number.parseInt(positionStr, 10) : undefined; if (position !== undefined && !Number.isFinite(position)) { throw new CrmCliError( `--position must be an integer, got "${positionStr}"`, ); } assertNoUnknownFlags(ctx.args, "crm fields enum-add"); out( ctx, await callMutation(ctx, api.fields.addEnumValue, { objectName, fieldName, value, color, position, }), ); return; } case "enum-remove": { const objectName = shift(ctx.args, "object name"); const fieldName = shift(ctx.args, "field name"); const value = shift(ctx.args, "enum value"); assertNoUnknownFlags(ctx.args, "crm fields enum-remove"); out( ctx, await callMutation(ctx, api.fields.removeEnumValue, { objectName, fieldName, value, }), ); return; } case "enum-rename": { const objectName = shift(ctx.args, "object name"); const fieldName = shift(ctx.args, "field name"); const oldValue = shift(ctx.args, "old enum value"); const newValue = shift(ctx.args, "new enum value"); assertNoUnknownFlags(ctx.args, "crm fields enum-rename"); out( ctx, await callMutation(ctx, api.fields.renameEnumValue, { objectName, fieldName, oldValue, newValue, }), ); return; } case "enum-set-color": { const objectName = shift(ctx.args, "object name"); const fieldName = shift(ctx.args, "field name"); const value = shift(ctx.args, "enum value"); const color = shift(ctx.args, "hex color"); assertNoUnknownFlags(ctx.args, "crm fields enum-set-color"); out( ctx, await callMutation(ctx, api.fields.setEnumColor, { objectName, fieldName, value, color, }), ); return; } default: throw new CrmCliError(`Unknown crm fields verb: ${verb ?? ""}`); } } async function runEntriesCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const objectName = shift(ctx.args, "object name"); const withMeta = hasFlag(ctx.args, "--with-meta"); const withReferences = hasFlag(ctx.args, "--with-references"); const cursorSpecified = ctx.args.includes("--cursor"); const cursorValue = ctx.args[ctx.args.indexOf("--cursor") + 1]; if ( cursorSpecified && (!cursorValue?.trim() || cursorValue.startsWith("--")) ) throw new CrmCliError( "--cursor requires a non-empty continuation cursor.", { cause: new CliArgError("Invalid --cursor") }, ); const cursor = getFlag(ctx.args, "--cursor"); if (cursorSpecified && (!cursor?.trim() || cursor.startsWith("--"))) throw new CrmCliError( "--cursor requires a non-empty continuation cursor.", ); const assigneeScopeArgs = resolveAssigneeScopeFlags(ctx.args); const limit = resolveLimitFlag(ctx.args, { defaultValue: 100, cap: withMeta && assigneeScopeArgs.assignedTo !== undefined ? 100 : 1000, command: "crm entries list", ...(withMeta ? { fullDatasetHint: "Continue with --with-meta --cursor '' when a cursor is returned.", } : {}), }); if (cursor !== undefined && !withMeta) { throw new CrmCliError( "--cursor requires --with-meta on crm entries list.", ); } assertNoUnknownFlags(ctx.args, "crm entries list"); if (!withMeta) { // Bare-array output: the clamp warning already went to stderr // (stdout must stay a valid JSON array in --json mode). outWithReferences( ctx, await callQuery( ctx, withReferences ? api.entries.listWithReferences : api.entries.list, { objectName, limit: limit.effective, ...assigneeScopeArgs, }, ), ); return; } // Catalog counts include hidden and unassigned records. Only the // readable page can tell us whether this caller has a continuation. const [result, object] = (await Promise.all([ assigneeScopeArgs.assignedTo !== undefined ? callQuery(ctx, api.query.scanEntries, { objectName, dsl: { limit: limit.effective }, withReferences: true, paginationOpts: { cursor: cursor ?? null, numItems: limit.effective, }, ...assigneeScopeArgs, }) : callQuery(ctx, api.entries.listPage, { objectName, limit: limit.effective, withReferences: true, ...(cursor !== undefined ? { cursor } : {}), }), callQuery(ctx, api.objects.get, { name: objectName }), ])) as [ { page?: unknown[]; objectName?: string; references?: WorkspaceReference[]; referenceCoverage?: { available: number }; isDone?: boolean; continueCursor?: string | null; truncated?: boolean; } | null, { entryCount?: number; visibility?: string } | null, ]; const entries = Array.isArray(result?.page) ? result.page : []; const isDone = typeof result?.isDone === "boolean" ? result.isDone : null; const truncated = result?.truncated === true; const continueCursor = isDone === false ? result?.continueCursor || null : null; const hasMore = continueCursor ? true : isDone === true && !truncated ? false : null; outWithReferences(ctx, { ...(result?.references ? boundWorkspaceReferences( result.references, result.referenceCoverage?.available, ) : {}), objectName: result?.objectName ?? objectName, totalCount: typeof object?.entryCount === "number" ? object.entryCount : null, totalCountScope: "object", visibility: object?.visibility ?? null, returnedCount: entries.length, returnedCountScope: "readable", limit: limit.effective, ...(limit.clamped ? { limitClamped: { requested: limit.requested, effective: limit.effective, }, } : {}), hasMore, isDone, truncated, continueCursor, entries, }); return; } case "sync-history": { // Queue the record's email addresses for a full, paged history pull // across the org's mailboxes; `--status` only reads the sync ledger. const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const statusOnly = hasFlag(ctx.args, "--status"); assertNoUnknownFlags(ctx.args, "crm entries sync-history"); if (!statusOnly) { await callMutation( ctx, makeFunctionReference<"mutation">( "functions/emailIdentityHistory:requestForEntry", ), { objectName, entryId }, ); } const status = (await callQuery( ctx, makeFunctionReference<"query">( "functions/emailIdentityHistory:statusForEntry", ), { objectName, entryId }, )) as { syncing: boolean; rows: Array<{ emailKey: string; mailboxEmail: string; status: string; messagesFound: number; oldestSeenAt?: number; capped?: boolean; lastError?: string; }>; }; if (ctx.jsonOutput) { out(ctx, status); return; } if (status.rows.length === 0) { out( ctx, "No email history sync for this record (no address, or no mailbox connected).", ); return; } out( ctx, status.rows .map((row) => { const through = row.oldestSeenAt ? ` through ${new Date(row.oldestSeenAt).toISOString().slice(0, 10)}` : ""; const capped = row.capped ? " (capped)" : ""; const error = row.lastError ? ` — ${row.lastError}` : ""; return `${row.emailKey} ← ${row.mailboxEmail}: ${row.status}, ${row.messagesFound} messages${through}${capped}${error}`; }) .join("\n"), ); return; } case "dossier": { // The account's research dossier lives INSIDE the record's Notes // (the `dench:dossier` section); this verb reads its status and, with // --regenerate, queues a fresh section. Read the text itself with // `dench crm entries get company ` (Notes) or `dench crm activity`. const entryId = shift(ctx.args, "entry id"); const regenerate = hasFlag(ctx.args, "--regenerate"); assertNoUnknownFlags(ctx.args, "crm entries dossier"); if (regenerate) { await callMutation( ctx, makeFunctionReference<"mutation">("functions/crm/dossier:regenerate"), { entryId }, ); } const status = (await callQuery( ctx, makeFunctionReference<"query">("functions/crm/dossier:getForEntry"), { entryId }, )) as { status: string; reason: string; generatedAt?: number; updatesAppended: number; removedByHumanAt?: number; error?: string; } | null; if (ctx.jsonOutput) { out(ctx, status); return; } if (!status) { out( ctx, "No dossier has been requested for this record. Run with --regenerate to research it now (the record needs a Website or Domain).", ); return; } const when = status.generatedAt ? ` · written ${new Date(status.generatedAt).toISOString().slice(0, 10)}` : ""; const updates = status.updatesAppended > 0 ? ` · ${status.updatesAppended} update${status.updatesAppended === 1 ? "" : "s"} since` : ""; const removed = status.removedByHumanAt ? " · removed from Notes by a member (regenerate to bring it back)" : ""; const error = status.error ? `\n${status.error}` : ""; out( ctx, `Dossier: ${status.status} (${status.reason})${when}${updates}${removed}${error}\nThe dossier is the \`\` section of the record's Notes.`, ); return; } case "get": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); // `--with-context` appends the record's ledger head (newest first) so // one call answers "what is going on here" — the agent's default read. const withContext = hasFlag(ctx.args, "--with-context"); const compact = hasFlag(ctx.args, "--compact"); assertNoUnknownFlags(ctx.args, "crm entries get"); const entry = withCrmEntryDeepLinks( ctx, objectName, withContext ? await callAction( ctx, makeFunctionReference<"action">( "functions/crm/recordRead:getEntry", ), { objectName, entryId, withContext: true }, ) : await callQuery(ctx, api.entries.get, { objectName, entryId: entryId as any, }), ); const printEntry = (value: unknown) => { if (compact) { // Normalize references before measuring the exact emitted JSON. No // pretty-printer or URL expansion may grow the bounded result later. console.log( serializeCompactCrmEntry( withWorkspaceReferenceEnvelopeUrls(value, ctx), { objectName, entryId, withContext }, ), ); } else outWithReferences(ctx, value); }; printEntry(entry); return; } case "create": { const objectName = shift(ctx.args, "object name"); const dataRaw = getDataFlag(ctx.args); // Optional idempotency: the server upserts on (object, key), so a // retried create (agent saw a null `.id`, ran the command again) // returns the existing row with `alreadyExisted: true` instead of // duplicating it. const idempotencyKey = getFlag(ctx.args, "--idempotency-key"); const allowUnknown = hasFlag(ctx.args, "--allow-unknown"); assertNoUnknownFlags(ctx.args, "crm entries create"); // Refuse silent blank-row creation. The agent that triggered this // refactor passed `--fields` (which used to be silently dropped), // got an empty data object, and only realized after creating 15 // blank rows. Be explicit: callers must pass --data (or --fields) // with at least one key. if (dataRaw === undefined) { throw new CrmCliError( 'dench crm entries create requires --data \'{"Field":"Value",...}\' (alias: --fields)', ); } rejectAtFileValue(dataRaw, "--data"); const data = (parseJson(dataRaw) as JsonRecord | undefined) ?? {}; if (!data || Object.keys(data).length === 0) { throw new CrmCliError( 'dench crm entries create: --data must include at least one field. Pass --data \'{"Name":"…"}\' or use cells set after creation.', ); } if (idempotencyKey !== undefined && !idempotencyKey.trim()) { throw new CrmCliError( "dench crm entries create: --idempotency-key must be a non-empty string", ); } // Single-row writes rely on the server's schema validation (which // returns the same "did you mean …" hint the CLI would). No local // pre-fetch — that would add a round-trip to every create. outWithReferences( ctx, withCrmEntryDeepLinks( ctx, objectName, await callMutation(ctx, api.entries.create, { objectName, data, ...runContextArgs(ctx), ...(allowUnknown ? { allowUnknown: true } : {}), ...(idempotencyKey ? { seedKey: idempotencyKey.trim() } : {}), }), ), ); return; } case "create-many": { const objectName = shift(ctx.args, "object name"); const dataRaw = getDataFlag(ctx.args); const idempotencyKey = getFlag(ctx.args, "--idempotency-key"); // Match ordinary existing records by this field, retaining the // stable seedKey for retry compatibility with earlier CLI versions. const dedupeColumn = parseDedupeFieldFlag( getFlag(ctx.args, "--dedupe-key"), ); const onConflictFlag = getFlag(ctx.args, "--on-conflict"); const onConflict = parseOnConflictFlag(onConflictFlag); if (onConflictFlag !== undefined && !dedupeColumn) { throw new CrmCliError( "crm entries create-many: --on-conflict requires --dedupe-key ", ); } const allowUnknown = hasFlag(ctx.args, "--allow-unknown"); const rows = parseCreateManyRows( await readJsonArrayInput({ args: ctx.args, command: "crm entries create-many", inlineFlag: "--data", inlineValue: dataRaw, }), ); await assertKnownDataKeys(ctx, objectName, rows, allowUnknown); const seedKeys = dedupeColumn ? rows.map((row, index) => dedupeKeyFromColumn({ prefix: `many:${objectName}:${dedupeColumn}`, column: dedupeColumn, row, rowIndex: index, command: "crm entries create-many", }), ) : null; const { chunks, results, references } = await applyBatchOpsInChunks( ctx, rows.map((data, index) => ({ type: "entries.create", objectName, data, ...(seedKeys ? { seedKey: seedKeys[index], matchField: dedupeColumn, rowIndex: index + 1, } : {}), })), idempotencyKey, allowUnknown, seedKeys ? onConflict : undefined, true, ); // Only claim per-row links when we actually got a result per row. A // silently-empty `results` is indistinguishable from "this object has // no links" and sends the agent right back to inventing URLs — the // exact failure this command's `url`/`href` output exists to prevent. const linked = results.length; const statusCounts = seedKeys && results.length > 0 ? countResultStatuses(results) : null; outWithReferences(ctx, { ...boundWorkspaceReferences(references, results.length), created: statusCounts ? statusCounts.created : rows.length, ...(statusCounts ? { alreadyExisted: statusCounts.alreadyExisted, updated: statusCounts.updated, } : {}), chunks, ...(linked > 0 ? { results: results.map((result) => withCrmEntryDeepLinks(ctx, objectName, result), ), } : {}), ...(linked === rows.length ? {} : { linksUnavailable: true, // The recovery path has to be followable. With zero results // there is no entryId to hand to `entries get`, so point at // `entries list` (it returns `id` per row) instead — a hint // the agent can't act on just sends it back to guessing. linksHint: `Got ${linked} link(s) for ${rows.length} row(s). ` + (linked === 0 ? `Run \`dench crm entries list ${objectName} --json\` to get each row's ` + `\`id\`, then \`dench crm entries get ${objectName} --json\` for its ` + "canonical url" : `The rows above carry \`url\`/\`href\`; for the rest run ` + `\`dench crm entries list ${objectName} --json\` to get their \`id\`, then ` + `\`dench crm entries get ${objectName} --json\``) + " — do not construct a url by hand.", }), }); return; } case "update": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const dataRaw = getDataFlag(ctx.args); const allowUnknown = hasFlag(ctx.args, "--allow-unknown"); assertNoUnknownFlags(ctx.args, "crm entries update"); if (dataRaw === undefined) { throw new CrmCliError( 'dench crm entries update requires --data \'{"Field":"Value",...}\' (alias: --fields)', ); } rejectAtFileValue(dataRaw, "--data"); const data = (parseJson(dataRaw) as JsonRecord | undefined) ?? {}; if (!data || Object.keys(data).length === 0) { throw new CrmCliError( 'dench crm entries update: --data must include at least one field to change. Pass --data \'{"Field":"…"}\'.', ); } // Single-row writes rely on the server's schema validation (same // "did you mean …" hint, no extra round-trip). out( ctx, await callMutation(ctx, api.entries.update, { objectName, entryId: entryId as any, data, ...runContextArgs(ctx), ...(allowUnknown ? { allowUnknown: true } : {}), }), ); return; } case "update-many": { const objectName = shift(ctx.args, "object name"); const updatesRaw = getUpdatesFlag(ctx.args); const idempotencyKey = getFlag(ctx.args, "--idempotency-key"); const allowUnknown = hasFlag(ctx.args, "--allow-unknown"); const updates = parseUpdateManyRows( await readJsonArrayInput({ args: ctx.args, command: "crm entries update-many", inlineFlag: "--updates", inlineValue: updatesRaw, }), ); await assertKnownDataKeys( ctx, objectName, updates.map((update) => update.data), allowUnknown, ); const { chunks } = await applyBatchOpsInChunks( ctx, updates.map((update) => ({ type: "entries.update", entryId: update.id, data: update.data, })), idempotencyKey, allowUnknown, ); out(ctx, { updated: updates.length, chunks }); return; } case "delete": case "remove": { const _objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); assertNoUnknownFlags(ctx.args, "crm entries delete"); if (ctx.args.length > 0) { throw new CrmCliError( "crm entries delete accepts exactly one entry ID. For multiple entries, use dench crm entries bulk-delete --ids id1,id2,... or crm batch with entries.delete operations.", ); } out( ctx, await callMutation(ctx, api.entries.remove, { entryId: entryId as any, ...runContextArgs(ctx), }), ); return; } case "bulk-delete": case "bulkDelete": { const objectName = shift(ctx.args, "object name"); const idsCsv = getFlag(ctx.args, "--ids") ?? ""; assertNoUnknownFlags(ctx.args, "crm entries bulk-delete"); const entryIds = idsCsv .split(",") .map((id) => id.trim()) .filter(Boolean); if (entryIds.length === 0) { throw new CrmCliError( "dench crm entries bulk-delete requires --ids id1,id2,…", ); } // Automatic undo file: snapshot the target rows to JSONL BEFORE // deleting. A bulk-delete driven by a buggy dedupe script is only // recoverable if the pre-delete state exists somewhere — this file // is that somewhere. Snapshot failure aborts the delete. const snapshot = await snapshotEntriesBeforeBulkDelete( ctx, objectName, entryIds, ); const result = await callMutation(ctx, api.entries.bulkDelete, { entryIds: entryIds as any, ...runContextArgs(ctx), }); out(ctx, { ...(isJsonRecord(result) ? result : { result }), snapshotPath: snapshot.path, snapshotRows: snapshot.rows, }); return; } default: throw new CrmCliError(`Unknown crm entries verb: ${verb ?? ""}`); } } const SNAPSHOT_RETRY_DELAYS_MS = [300, 900, 2_700]; /** * A Convex application error (`ConvexError`) carries `data`; a transport * or server blip (fetch failure, 5xx, overloaded) is a plain Error without * it. Only the latter is worth retrying — "Entry not found" will not fix * itself. */ export function isTransientRpcError(error: unknown): boolean { if (!error || typeof error !== "object") return false; if ((error as { data?: unknown }).data !== undefined) return false; return error instanceof Error; } /** * One read per target row means a bulk-delete snapshot of N rows is N * sequential round-trips; a single dropped connection used to abort the * whole command ("could not snapshot"). Retry transient failures with a * short backoff before giving up. */ async function queryWithTransientRetry( ctx: CrmCliContext, fn: Parameters[0], args: Record, ): Promise { for (let attempt = 0; ; attempt++) { try { return await ctx.convex.query(fn, { ...args, ...(ctx.sessionToken ? { sessionToken: ctx.sessionToken } : {}), } as never); } catch (error) { const delay = SNAPSHOT_RETRY_DELAYS_MS[attempt]; if (delay === undefined || !isTransientRpcError(error)) { throw normalizeRpcError(error); } process.stderr.write( `warning: transient error reading entry for the bulk-delete snapshot ` + `(${error instanceof Error ? error.message : String(error)}); ` + `retrying in ${delay}ms (${attempt + 1}/${SNAPSHOT_RETRY_DELAYS_MS.length}).\n`, ); await new Promise((resolve) => setTimeout(resolve, delay)); } } } /** * Fetch every target row and write it to a JSONL snapshot under the OS * temp dir ($TMPDIR or /tmp). This is an automatic undo file, not an * interactive gate: the delete proceeds after a successful snapshot and * ABORTS if the snapshot cannot be captured or written. */ async function snapshotEntriesBeforeBulkDelete( ctx: CrmCliContext, objectName: string, entryIds: string[], ): Promise<{ path: string; rows: number }> { const lines: string[] = []; try { for (const entryId of entryIds) { const entry = await queryWithTransientRetry(ctx, api.entries.get, { objectName, entryId: entryId as any, }); lines.push(JSON.stringify(entry)); } } catch (error) { throw new CrmCliError( "bulk-delete aborted: could not snapshot the target rows before " + `deleting (${error instanceof Error ? error.message : String(error)}). ` + "No entries were deleted.", ); } const os = await import("node:os"); const path = await import("node:path"); const fs = await import("node:fs/promises"); const snapshotPath = path.join( os.tmpdir(), `dench-bulk-delete-${objectName.replace(/[^A-Za-z0-9._-]+/g, "_")}-${Date.now()}.jsonl`, ); try { await fs.writeFile( snapshotPath, lines.length > 0 ? `${lines.join("\n")}\n` : "", "utf-8", ); } catch (error) { throw new CrmCliError( `bulk-delete aborted: failed to write snapshot file ${snapshotPath} ` + `(${error instanceof Error ? error.message : String(error)}). ` + "No entries were deleted.", ); } return { path: snapshotPath, rows: lines.length }; } async function runHistoryCommand(ctx: CrmCliContext): Promise { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const includeSystem = hasFlag(ctx.args, "--include-system"); const limit = parseInt(getFlag(ctx.args, "--limit") ?? "50", 10); const cursor = getFlag(ctx.args, "--cursor"); assertNoUnknownFlags(ctx.args, "crm history"); out( ctx, await callQuery(ctx, api.history.listForEntry, { objectName, entryId, includeSystem, paginationOpts: { numItems: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 100) : 50, cursor: cursor ?? null, }, }), ); } async function runCellsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "get": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const fieldName = shift(ctx.args, "field name"); assertNoUnknownFlags(ctx.args, "crm cells get"); out( ctx, await callQuery(ctx, api.cells.get, { objectName, entryId: entryId as any, fieldName, }), ); return; } case "set": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const fieldName = shift(ctx.args, "field name"); const value = parseJson(ctx.args.shift()) ?? null; assertNoUnknownFlags(ctx.args, "crm cells set"); out( ctx, await callMutation(ctx, api.cells.set, { objectName, entryId: entryId as any, fieldName, value, ...runContextArgs(ctx), }), ); return; } case "set-many": { const _objectName = shift(ctx.args, "object name"); const updatesRaw = getUpdatesFlag(ctx.args); const idempotencyKey = getFlag(ctx.args, "--idempotency-key"); const updates = parseCellSetManyRows( await readJsonArrayInput({ args: ctx.args, command: "crm cells set-many", inlineFlag: "--updates", inlineValue: updatesRaw, }), ); const grouped = new Map(); for (const update of updates) { const data = grouped.get(update.entryId) ?? {}; data[update.field] = update.value; grouped.set(update.entryId, data); } const ops: CrmBatchOp[] = Array.from(grouped.entries()).map( ([entryId, data]) => ({ type: "entries.update", entryId, data, }), ); const { chunks } = await applyBatchOpsInChunks(ctx, ops, idempotencyKey); out(ctx, { updated: updates.length, entriesUpdated: ops.length, chunks }); return; } case "append": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const fieldName = shift(ctx.args, "field name"); const value = parseJson(ctx.args.shift()) ?? null; assertNoUnknownFlags(ctx.args, "crm cells append"); out( ctx, await callMutation(ctx, api.cells.append, { objectName, entryId: entryId as any, fieldName, value, ...runContextArgs(ctx), }), ); return; } default: throw new CrmCliError(`Unknown crm cells verb: ${verb ?? ""}`); } } async function runQueryCommand(ctx: CrmCliContext): Promise { const objectName = shift(ctx.args, "object name"); const withReferences = hasFlag(ctx.args, "--with-references"); const filter = parseJson(getFlag(ctx.args, "--where")) as | JsonRecord | undefined; const select = getFlag(ctx.args, "--select")?.split(",").filter(Boolean); const sort = getFlag(ctx.args, "--sort"); const cursor = getFlag(ctx.args, "--cursor"); const pageMode = hasFlag(ctx.args, "--page") || cursor !== undefined; // The server clamps at 1000 silently; surface the clamp on stderr so // callers never mistake a truncated page for the full dataset. Output // here is a bare JSON array, so stderr is the only safe channel. const limit = resolveLimitFlag(ctx.args, { defaultValue: 100, cap: pageMode ? 100 : 1000, command: "crm query", }); const assigneeScopeArgs = resolveAssigneeScopeFlags(ctx.args); assertNoUnknownFlags(ctx.args, "crm query"); // Convex's wire serializer rejects $-prefixed object keys, so we // route the DSL through the dslJson string field whenever the filter // has any operator-shaped clause. const dsl = { filter, select, sort, limit: limit.effective }; const hasDollarKey = filter ? containsDollarKey(filter) : false; const args = hasDollarKey ? { objectName, dslJson: JSON.stringify(dsl), ...assigneeScopeArgs, ...(pageMode ? { withReferences: true } : {}), } : { objectName, dsl, ...assigneeScopeArgs, ...(pageMode ? { withReferences: true } : {}), }; outWithReferences( ctx, pageMode ? await callQuery(ctx, api.query.scanEntries, { ...args, paginationOpts: { cursor: cursor ?? null, numItems: limit.effective }, }) : await callAction( ctx, withReferences ? api.query.queryEntriesWithReferences : api.query.queryEntries, args, ), ); } export function containsDollarKey(value: unknown): boolean { if (!value || typeof value !== "object") return false; if (Array.isArray(value)) return value.some(containsDollarKey); for (const key of Object.keys(value)) { if (key.startsWith("$")) return true; if (containsDollarKey((value as Record)[key])) { return true; } } return false; } async function runAggregateCommand(ctx: CrmCliContext): Promise { const objectName = shift(ctx.args, "object name"); const fieldName = getFlag(ctx.args, "--field"); if (!fieldName) throw new CrmCliError("--field is required"); // `--sum Amount` totals that number field per bucket (exact from the // pre-aggregates: "Amount by Stage" at any table size). const sumField = getFlag(ctx.args, "--sum"); assertNoUnknownFlags(ctx.args, "crm aggregate"); out( ctx, await callQuery(ctx, api.query.aggregate, { objectName, fieldName, ...(sumField ? { sumField } : {}), }), ); } async function runSearchCommand(ctx: CrmCliContext): Promise { const objectName = getFlag(ctx.args, "--object"); const withReferences = hasFlag(ctx.args, "--with-references"); const selectRaw = getFlag(ctx.args, "--select"); const select = selectRaw?.split(",").map((name) => name.trim()); const selectionModeFlag = getFlag(ctx.args, "--selection-mode"); if ( selectionModeFlag !== undefined && selectionModeFlag !== "strict" && selectionModeFlag !== "compact" ) { throw new CrmCliError("--selection-mode must be strict or compact."); } if (selectionModeFlag !== undefined && (!objectName || !select)) { throw new CrmCliError("--selection-mode requires --object and --select."); } // Agent reads can recover a stale/unknown projection in the same request. // Scripts retain exact strict selection unless they explicitly opt in. const selectionMode = selectionModeFlag ?? (objectName && select && process.env.DENCH_HARNESS_A_JOB_ID?.trim() ? "compact" : undefined); if (select && (select.length > 30 || select.some((name) => !name))) { throw new CrmCliError( "--select requires between 1 and 30 nonempty field names.", ); } const page = hasFlag(ctx.args, "--page"); const cursor = getFlag(ctx.args, "--cursor"); const projected = select !== undefined || page || cursor !== undefined; const limit = resolveLimitFlag(ctx.args, { defaultValue: projected ? 20 : 50, cap: 100, command: "crm search", }); assertNoUnknownFlags(ctx.args, "crm search"); const text = ctx.args.join(" ").trim(); if (!text) throw new CrmCliError("Search text required"); outWithReferences( ctx, await callQuery( ctx, projected ? api.query.searchPage : withReferences ? api.query.searchWithReferences : api.query.search, { text, objectName, limit: limit.effective, ...(projected ? { withReferences: true } : {}), ...(projected ? { select, cursor } : {}), ...(selectionMode ? { selectionMode } : {}), }, ), ); } async function runStatusesCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const objectName = shift(ctx.args, "object name"); assertNoUnknownFlags(ctx.args, "crm statuses list"); out(ctx, await callQuery(ctx, api.statuses.list, { objectName })); return; } case "set": { const objectName = shift(ctx.args, "object name"); const statuses = parseJson(getFlag(ctx.args, "--statuses")) as any[]; assertNoUnknownFlags(ctx.args, "crm statuses set"); if (!Array.isArray(statuses)) { throw new CrmCliError("--statuses (JSON array) is required"); } out( ctx, await callMutation(ctx, api.statuses.upsertMany, { objectName, statuses, }), ); return; } default: throw new CrmCliError(`Unknown crm statuses verb: ${verb ?? ""}`); } } async function runBatchCommand(ctx: CrmCliContext): Promise { const filePath = getFlag(ctx.args, "--file"); // The help text advertises --idempotency-key; thread it through so the // doc and the implementation agree. const idempotencyKey = getFlag(ctx.args, "--idempotency-key"); assertNoUnknownFlags(ctx.args, "crm batch"); if (!filePath) { throw new CrmCliError( "dench crm batch requires --file (one op per line)", ); } const fs = await import("node:fs/promises"); const text = await fs.readFile(filePath, "utf-8"); const ops: unknown[] = []; for (const line of text.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; ops.push(JSON.parse(trimmed)); } if (ops.length === 0) { throw new CrmCliError(`No operations found in ${filePath}`); } out( ctx, await callMutation(ctx, api.batch.apply, { ops: ops as any, ...runContextArgs(ctx), ...(idempotencyKey ? { idempotencyKey } : {}), } as any), ); } async function runDocsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { assertNoUnknownFlags(ctx.args, "crm docs list"); out(ctx, await callQuery(ctx, api.documents.list, {})); return; } case "create": { const filePath = shiftName( ctx.args, "file path", "dench crm docs create [--title ...] [--icon ...] [--link-entry ]", ); const title = getFlag(ctx.args, "--title") ?? filePath.split("/").pop()!; const icon = getFlag(ctx.args, "--icon"); const linkEntryId = getFlag(ctx.args, "--link-entry"); assertNoUnknownFlags(ctx.args, "crm docs create"); out( ctx, await callMutation(ctx, api.documents.create, { filePath, title, icon, entryId: linkEntryId as any, }), ); return; } case "link": { const filePath = shiftName( ctx.args, "file path", "dench crm docs link --object --entry ", ); const objectName = getFlag(ctx.args, "--object"); const entryId = getFlag(ctx.args, "--entry"); assertNoUnknownFlags(ctx.args, "crm docs link"); out( ctx, await callMutation(ctx, api.documents.link, { filePath, objectName, entryId: entryId as any, }), ); return; } case "delete": { const documentId = shift(ctx.args, "document id"); assertNoUnknownFlags(ctx.args, "crm docs delete"); out(ctx, await callMutation(ctx, api.documents.remove, { documentId })); return; } default: throw new CrmCliError(`Unknown crm docs verb: ${verb ?? ""}`); } } async function runActionsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const entryId = shift(ctx.args, "entry id"); assertNoUnknownFlags(ctx.args, "crm actions list"); out( ctx, await callQuery(ctx, api.actions.list, { entryId: entryId as any, }), ); return; } case "run": { const objectName = shift(ctx.args, "object name"); const fieldId = shift(ctx.args, "field id"); const actionId = shift(ctx.args, "action id"); assertNoUnknownFlags(ctx.args, "crm actions run"); const entryIds = ctx.args.splice(0); if (entryIds.length === 0) { throw new CrmCliError("Provide at least one entry id"); } out( ctx, await callAction(ctx, api.actions.run, { objectName, fieldId: fieldId as any, actionId, entryIds: entryIds as any, }), ); return; } case "create": case "edit": { const usage = verb === "edit" ? "dench crm actions edit [--label ...]" : 'dench crm actions create --label "