/** * `dench crm comments …`, `dench crm watch …`, `dench crm tasks …` * * Thin dispatchers over the Convex functions the entry panel itself uses * (`functions/crm/comments`, `functions/crm/tasks`), so the CLI, the * `/api/v1` surface, and the UI share one contract. Kept out of `crm.ts` * only for file size. */ import { readFile } from "node:fs/promises"; import { makeFunctionReference } from "convex/server"; import { assertNoUnknownFlags, type CrmCliContext, CrmCliError, callAction, callMutation, callQuery, out, shift, } from "./crm"; import { getFlag, hasFlag } from "./lib/cli-args"; import { buildRepeatBody, RepeatArgsError } from "./lib/task-repeat-args"; const api = { comments: { listForEntry: makeFunctionReference<"query">( "functions/crm/comments:listForEntry", ), listReplies: makeFunctionReference<"query">( "functions/crm/comments:listReplies", ), get: makeFunctionReference<"query">("functions/crm/comments:get"), create: makeFunctionReference<"action">( "functions/crm/comments:createAndDispatch", ), edit: makeFunctionReference<"mutation">("functions/crm/comments:edit"), remove: makeFunctionReference<"mutation">("functions/crm/comments:remove"), toggleReaction: makeFunctionReference<"mutation">( "functions/crm/comments:toggleReaction", ), getWatchState: makeFunctionReference<"query">( "functions/crm/comments:getWatchState", ), setWatching: makeFunctionReference<"mutation">( "functions/crm/comments:setWatching", ), }, brief: { getForEntry: makeFunctionReference<"query">( "functions/crm/intelligence:getForEntry", ), ensure: makeFunctionReference<"mutation">( "functions/crm/intelligence:ensure", ), regenerate: makeFunctionReference<"mutation">( "functions/crm/intelligence:regenerate", ), }, activity: { listForEntry: makeFunctionReference<"query">( "functions/crm/activity:listForEntry", ), countForEntry: makeFunctionReference<"query">( "functions/crm/activity:countForEntry", ), listNotesForEntry: makeFunctionReference<"query">( "functions/crm/activity:listNotesForEntry", ), }, tasks: { pageAssigned: makeFunctionReference<"query">( "functions/crm/tasks:pageAssignedTasks", ), pageEntry: makeFunctionReference<"query">( "functions/crm/tasks:pageEntryTasks", ), prepare: makeFunctionReference<"mutation">( "functions/crm/taskReadModels:ensureReady", ), listMyOpenTasks: makeFunctionReference<"query">( "functions/crm/tasks:listMyOpenTasks", ), listEntryTasks: makeFunctionReference<"query">( "functions/crm/tasks:listEntryTasks", ), createEntryTask: makeFunctionReference<"mutation">( "functions/crm/tasks:createEntryTask", ), completeTask: makeFunctionReference<"mutation">( "functions/crm/tasks:completeTask", ), reopenTask: makeFunctionReference<"mutation">( "functions/crm/tasks:reopenTask", ), setTaskPriority: makeFunctionReference<"mutation">( "functions/crm/tasks:setTaskPriority", ), listSubtasks: makeFunctionReference<"query">( "functions/crm/tasks:listSubtasks", ), createSubtask: makeFunctionReference<"mutation">( "functions/crm/tasks:createSubtask", ), setTaskParent: makeFunctionReference<"mutation">( "functions/crm/tasks:setTaskParent", ), deleteTask: makeFunctionReference<"action">( "functions/crm/tasks:deleteTaskComplete", ), }, repeat: { get: makeFunctionReference<"query">( "functions/crm/taskRecurrences:getForTask", ), set: makeFunctionReference<"mutation">( "functions/crm/taskRecurrences:setForTask", ), clear: makeFunctionReference<"mutation">( "functions/crm/taskRecurrences:clearForTask", ), skip: makeFunctionReference<"mutation">( "functions/crm/taskRecurrences:skip", ), pause: makeFunctionReference<"mutation">( "functions/crm/taskRecurrences:pause", ), resume: makeFunctionReference<"mutation">( "functions/crm/taskRecurrences:resume", ), listSeries: makeFunctionReference<"query">( "functions/crm/taskRecurrences:listSeries", ), listOccurrences: makeFunctionReference<"query">( "functions/crm/taskRecurrences:listOccurrences", ), }, }; /** Shared `--repeat …` flag family (create, create-subtask, repeat). */ function readRepeatFlags(args: string[], freq?: string) { return { freq, every: getFlag(args, "--every"), on: getFlag(args, "--on"), day: getFlag(args, "--day"), nth: getFlag(args, "--nth"), endsAfter: getFlag(args, "--ends-after"), until: getFlag(args, "--until"), onSchedule: hasFlag(args, "--on-schedule"), skipWeekends: hasFlag(args, "--skip-weekends"), noCopyNotes: hasFlag(args, "--no-copy-notes"), noKeepAssignee: hasFlag(args, "--no-keep-assignee"), noCopySubtasks: hasFlag(args, "--no-copy-subtasks"), rrule: getFlag(args, "--rrule"), }; } function repeatBodyOrThrow( input: ReturnType, dueKey?: string | null, ): Record { try { return buildRepeatBody(input, dueKey); } catch (error) { if (error instanceof RepeatArgsError) throw new CrmCliError(error.message); throw error; } } const DENCH_MENTION = "[@Dench](/?member=dench)"; function paginationFlags( args: string[], defaultLimit: number, ): { numItems: number; cursor: string | null } { const rawLimit = getFlag(args, "--limit"); const limit = rawLimit === undefined ? defaultLimit : Number.parseInt(rawLimit, 10); if (!Number.isFinite(limit) || limit < 1) { throw new CrmCliError("--limit must be a positive integer"); } return { numItems: Math.min(limit, 100), cursor: getFlag(args, "--cursor") ?? null, }; } /** `--body`, `--body-file`, or piped stdin — in that order. */ async function readBody(args: string[]): Promise { const inline = getFlag(args, "--body"); if (inline !== undefined) return inline; const file = getFlag(args, "--body-file"); if (file) return await readFile(file, "utf8"); if (!process.stdin.isTTY) { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const text = Buffer.concat(chunks).toString("utf8"); if (text.trim()) return text; } throw new CrmCliError( 'A comment body is required: pass --body "…", --body-file , or pipe markdown on stdin.', ); } /** Repeated `--mention ` flags. */ function collectMentions(args: string[]): string[] { const ids: string[] = []; for (;;) { const value = getFlag(args, "--mention"); if (value === undefined) break; if (value.trim()) ids.push(value.trim()); } return ids; } export async function runCommentsCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const paginationOpts = paginationFlags(ctx.args, 20); const rawReplies = getFlag(ctx.args, "--replies"); const previewReplies = rawReplies === undefined ? undefined : Number.parseInt(rawReplies, 10); const kind = readKindFlag(ctx.args); const order = readOrderFlag(ctx.args); assertNoUnknownFlags(ctx.args, "crm comments list"); out( ctx, await callQuery(ctx, api.comments.listForEntry, { objectName, entryId, paginationOpts, ...(previewReplies !== undefined && Number.isFinite(previewReplies) ? { previewReplies } : {}), ...(kind ? { kind } : {}), ...(order ? { order } : {}), }), ); return; } case "replies": { const rootCommentId = shift(ctx.args, "comment id"); const paginationOpts = paginationFlags(ctx.args, 50); assertNoUnknownFlags(ctx.args, "crm comments replies"); out( ctx, await callQuery(ctx, api.comments.listReplies, { rootCommentId, paginationOpts, }), ); return; } case "get": { const commentId = shift(ctx.args, "comment id"); assertNoUnknownFlags(ctx.args, "crm comments get"); out(ctx, await callQuery(ctx, api.comments.get, { commentId })); return; } case "create": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); let body = await readBody(ctx.args); const parentCommentId = getFlag(ctx.args, "--reply-to"); const mentionUserIds = collectMentions(ctx.args); const mentionDench = hasFlag(ctx.args, "--dench"); const kind = readKindFlag(ctx.args); const title = getFlag(ctx.args, "--title"); assertNoUnknownFlags(ctx.args, "crm comments create"); if (mentionDench && !body.includes("/?member=dench")) { body = `${DENCH_MENTION} ${body}`; } out( ctx, await callAction(ctx, api.comments.create, { objectName, entryId, body, ...(parentCommentId ? { parentCommentId } : {}), ...(mentionUserIds.length > 0 ? { mentionUserIds } : {}), ...(mentionDench ? { mentionDench: true } : {}), ...(kind ? { kind } : {}), ...(title !== undefined ? { title } : {}), ...(ctx.runId ? { runId: ctx.runId } : {}), }), ); return; } case "update": { const commentId = shift(ctx.args, "comment id"); const body = await readBody(ctx.args); const title = getFlag(ctx.args, "--title"); assertNoUnknownFlags(ctx.args, "crm comments update"); out( ctx, await callMutation(ctx, api.comments.edit, { commentId, body, ...(title !== undefined ? { title } : {}), }), ); return; } case "delete": { const commentId = shift(ctx.args, "comment id"); assertNoUnknownFlags(ctx.args, "crm comments delete"); out(ctx, await callMutation(ctx, api.comments.remove, { commentId })); return; } case "react": { const commentId = shift(ctx.args, "comment id"); const emoji = shift(ctx.args, "emoji"); assertNoUnknownFlags(ctx.args, "crm comments react"); out( ctx, await callMutation(ctx, api.comments.toggleReaction, { commentId, emoji, }), ); return; } default: throw new CrmCliError( `Unknown comments verb: ${verb ?? "(none)"}. Use list | replies | get | create | update | delete | react.`, ); } } function readKindFlag(args: string[]): "comment" | "note" | undefined { const raw = getFlag(args, "--kind"); if (raw === undefined) return undefined; if (raw === "comment" || raw === "note") return raw; throw new CrmCliError("--kind must be `comment` or `note`"); } function readOrderFlag(args: string[]): "asc" | "desc" | undefined { const raw = getFlag(args, "--order"); if (raw === undefined) return undefined; if (raw === "asc" || raw === "desc") return raw; throw new CrmCliError("--order must be `asc` or `desc`"); } const ACTIVITY_KINDS = [ "comment", "note", "change", "email_in", "email_out", "calendar_event", "calendar_notice", "meeting", "slack_message", "linkedin_in", "linkedin_out", "linkedin_connection", ] as const; type ActivityKind = (typeof ACTIVITY_KINDS)[number]; /** `--kind email,meeting` — accepts the ledger kinds plus family aliases. */ function readActivityKinds(args: string[]): ActivityKind[] | undefined { const raw = getFlag(args, "--kind"); if (raw === undefined) return undefined; const out = new Set(); for (const token of raw.split(",").map((part) => part.trim())) { if (!token) continue; switch (token) { case "email": case "emails": out.add("email_in"); out.add("email_out"); break; case "meetings": out.add("calendar_event"); out.add("calendar_notice"); out.add("meeting"); break; case "linkedin": out.add("linkedin_in"); out.add("linkedin_out"); out.add("linkedin_connection"); break; case "slack": out.add("slack_message"); break; case "changes": out.add("change"); break; case "comments": out.add("comment"); break; case "notes": out.add("note"); break; default: if ((ACTIVITY_KINDS as readonly string[]).includes(token)) { out.add(token as ActivityKind); } else { throw new CrmCliError( `Unknown activity kind "${token}". Use one of: ${ACTIVITY_KINDS.join(", ")} (or email, meetings, linkedin, slack, changes, comments, notes).`, ); } } } return out.size > 0 ? [...out] : undefined; } /** `--since 30d | 12h | 2026-09-01` → epoch ms. */ export function readSince(args: string[]): number | undefined { const raw = getFlag(args, "--since"); if (raw === undefined) return undefined; const rel = /^(\d+)([dhwm])$/i.exec(raw.trim()); if (rel) { const n = Number.parseInt(rel[1], 10); const unit = rel[2].toLowerCase(); const ms = unit === "h" ? 3_600_000 : unit === "d" ? 86_400_000 : unit === "w" ? 7 * 86_400_000 : 30 * 86_400_000; return Date.now() - n * ms; } const parsed = Date.parse(raw); if (Number.isNaN(parsed)) { throw new CrmCliError("--since must look like 30d, 12h, 2w, 3m or a date"); } return parsed; } /** * `dench crm activity ` — the record's ledger, newest first * by default (the agent wants the latest touch on top), with the Notes * document appended last so one call answers "what is going on here". */ export async function runActivityCommand(ctx: CrmCliContext): Promise { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const kinds = readActivityKinds(ctx.args); const order = readOrderFlag(ctx.args) ?? "desc"; const since = readSince(ctx.args); const rawLimit = getFlag(ctx.args, "--limit"); const limit = rawLimit === undefined ? 40 : Number.parseInt(rawLimit, 10); if (!Number.isFinite(limit) || limit < 1 || limit > 200) { throw new CrmCliError("--limit must be an integer between 1 and 200"); } const before = getFlag(ctx.args, "--before"); const after = getFlag(ctx.args, "--after"); const includeSystem = hasFlag(ctx.args, "--include-system"); const withNotes = !hasFlag(ctx.args, "--no-notes"); const withBrief = !hasFlag(ctx.args, "--no-brief"); assertNoUnknownFlags(ctx.args, "crm activity"); const page = (await callQuery(ctx, api.activity.listForEntry, { objectName, entryId, order, limit, includeSystem, ...(kinds ? { kinds } : {}), ...(since !== undefined ? { after: since } : {}), ...(before !== undefined ? { before: Number(before) } : {}), ...(after !== undefined ? { after: Number(after) } : {}), })) as { items: Array>; hasMore: boolean; nextBoundary: number | null; }; const counts = await callQuery(ctx, api.activity.countForEntry, { objectName, entryId, includeSystem, ...(kinds ? { kinds } : {}), }); let brief: BriefResponse | null = null; if (withBrief) { brief = await readBrief(ctx, objectName, entryId); } let notes: unknown; if (withNotes) { const entry = (await callQuery( ctx, makeFunctionReference<"query">("functions/crm/entries:get"), { objectName, entryId }, )) as { fields?: Record } | null; const raw = entry?.fields?.Notes ?? entry?.fields?.notes; notes = typeof raw === "string" && raw.trim() ? raw : null; } if (ctx.jsonOutput) { out(ctx, { order, ...(withBrief ? { brief } : {}), ...page, counts, ...(withNotes ? { notes } : {}), }); return; } const lines: string[] = []; lines.push( `Activity for ${objectName}/${entryId} (${order === "desc" ? "newest first" : "oldest first"}, ${(counts as { total: number; capped: boolean }).total}${(counts as { capped: boolean }).capped ? "+" : ""} total)`, ); if (withBrief) { lines.push(...formatBriefLines(brief)); lines.push(""); } for (const item of page.items) lines.push(formatActivityLine(item)); if (page.hasMore && page.nextBoundary !== null) { lines.push( `… more — continue with --${order === "desc" ? "before" : "after"} ${page.nextBoundary}`, ); } if (withNotes) { lines.push(""); lines.push("Notes document:"); lines.push(typeof notes === "string" ? notes : "(empty)"); } out(ctx, lines.join("\n")); } type BriefRow = { status: "generating" | "ready" | "failed"; headline?: string; body?: string; facets?: { relationshipStatus?: string; awaitingReplyFrom: "us" | "them" | "none"; health: string; openCommitments: number; }; generatedAt?: number; stale: boolean; error?: string; }; type BriefResponse = { shared: BriefRow | null; hasMaterial: boolean; audience?: "viewer" | "workspace"; }; async function readBrief( ctx: CrmCliContext, objectName: string, entryId: string, includeContext = false, ): Promise { return (await callMutation( ctx, makeFunctionReference<"mutation">( "functions/crm/intelligence:readForEntry", ), { objectName, entryId, audience: "viewer", ...(includeContext ? { includeContext: true } : {}), }, )) as BriefResponse; } function formatBriefLines(brief: BriefResponse | null): string[] { const lead = brief?.shared ?? null; if (!brief?.hasMaterial) return ["Brief: nothing on this record yet."]; if (!lead || (lead.status === "generating" && !lead.body)) { return [ "Brief: preparing — re-run in a minute (dench crm brief ).", ]; } const lines: string[] = []; const facets = lead.facets ? ` [${lead.facets.health} · awaiting: ${lead.facets.awaitingReplyFrom} · ${lead.facets.openCommitments} open commitment${lead.facets.openCommitments === 1 ? "" : "s"}]` : ""; const when = lead.generatedAt ? ` (${new Date(lead.generatedAt).toISOString()}${lead.stale ? ", newer activity exists" : ""})` : ""; lines.push( `Brief${brief.audience === "viewer" ? " (only you)" : ""}${facets}${when}`, ); if (lead.headline) lines.push(lead.headline); if (lead.body) lines.push(lead.body); return lines; } /** * `dench crm brief ` — the record's generated Brief, the * agent's first read before `crm activity`. `--regenerate` rebuilds it. */ export async function runBriefCommand(ctx: CrmCliContext): Promise { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const regenerate = hasFlag(ctx.args, "--regenerate"); const includeContext = hasFlag(ctx.args, "--with-context"); assertNoUnknownFlags(ctx.args, "crm brief"); const brief = (await callAction( ctx, makeFunctionReference<"action">("functions/crm/recordRead:getBrief"), { objectName, entryId, ...(includeContext ? { includeContext: true } : {}), ...(regenerate ? { regenerate: true } : {}), }, )) as BriefResponse; if (ctx.jsonOutput || includeContext) { out(ctx, brief); return; } out(ctx, formatBriefLines(brief).join("\n")); } function formatActivityLine(item: Record): string { const at = new Date(item.at as number).toISOString(); if (item.type === "comment") { const c = item.comment as { kind: string; title?: string; body: string; actor: { displayName?: string; label: string }; replyCount: number; }; const who = c.actor.displayName ?? c.actor.label; const head = c.kind === "note" ? `NOTE ${c.title ?? ""}`.trim() : "COMMENT"; const body = c.body.replace(/\s+/g, " ").trim(); const replies = c.replyCount > 0 ? ` (+${c.replyCount} replies)` : ""; return `${at} ${head} ${who}: ${body.slice(0, 300)}${replies}`; } if (item.type === "change") { const row = item.row as { actor: { displayName?: string; label: string }; changeKind: string; changes: Array<{ fieldName: string }>; }; const who = row.actor.displayName ?? row.actor.label; const fields = row.changes.map((c) => c.fieldName).join(", "); return `${at} CHANGE ${who} ${row.changeKind}${fields ? `: ${fields}` : ""}`; } // A conversation: one line per thread, at its newest message. const t = item.thread as { kind: string; title: string; summary: string; messageCount: number; role: "direct" | "copied"; participants: Array<{ label: string }>; visibility: string; }; const who = t.participants .map((p) => p.label) .slice(0, 3) .join(", "); const tags = [ t.visibility === "workspace" ? "" : "private", t.role === "copied" ? "cc only" : "", t.messageCount > 1 ? `${t.messageCount} msgs` : "", ] .filter(Boolean) .join(", "); return `${at} ${t.kind.toUpperCase()}${tags ? ` [${tags}]` : ""} ${who ? `${who} — ` : ""}${t.title}${t.summary ? ` — ${t.summary}` : ""}`; } /** `dench crm notes …` — sugar over comments with `kind: note`. */ export async function runNotesCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "list": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const rawLimit = getFlag(ctx.args, "--limit"); const limit = rawLimit === undefined ? 50 : Number.parseInt(rawLimit, 10); if (!Number.isFinite(limit) || limit < 1) { throw new CrmCliError("--limit must be a positive integer"); } assertNoUnknownFlags(ctx.args, "crm notes list"); out( ctx, await callQuery(ctx, api.activity.listNotesForEntry, { objectName, entryId, limit, }), ); return; } case "get": { const commentId = shift(ctx.args, "note id"); assertNoUnknownFlags(ctx.args, "crm notes get"); out(ctx, await callQuery(ctx, api.comments.get, { commentId })); return; } case "create": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const body = await readBody(ctx.args); const title = getFlag(ctx.args, "--title"); const mentionUserIds = collectMentions(ctx.args); assertNoUnknownFlags(ctx.args, "crm notes create"); out( ctx, await callAction(ctx, api.comments.create, { objectName, entryId, body, kind: "note", ...(title !== undefined ? { title } : {}), ...(mentionUserIds.length > 0 ? { mentionUserIds } : {}), ...(ctx.runId ? { runId: ctx.runId } : {}), }), ); return; } case "update": { const commentId = shift(ctx.args, "note id"); const body = await readBody(ctx.args); const title = getFlag(ctx.args, "--title"); assertNoUnknownFlags(ctx.args, "crm notes update"); out( ctx, await callMutation(ctx, api.comments.edit, { commentId, body, ...(title !== undefined ? { title } : {}), }), ); return; } case "delete": { const commentId = shift(ctx.args, "note id"); assertNoUnknownFlags(ctx.args, "crm notes delete"); out(ctx, await callMutation(ctx, api.comments.remove, { commentId })); return; } default: throw new CrmCliError( `Unknown notes verb: ${verb ?? "(none)"}. Use list | get | create | update | delete.`, ); } } export async function runWatchCommand(ctx: CrmCliContext): Promise { if (ctx.args[0] === "status") { ctx.args.shift(); const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); assertNoUnknownFlags(ctx.args, "crm watch status"); void objectName; out(ctx, await callQuery(ctx, api.comments.getWatchState, { entryId })); return; } const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const off = hasFlag(ctx.args, "--off"); assertNoUnknownFlags(ctx.args, "crm watch"); void objectName; out( ctx, await callMutation(ctx, api.comments.setWatching, { entryId, watching: !off, }), ); } export async function runTasksCommand(ctx: CrmCliContext): Promise { const verb = ctx.args.shift(); switch (verb) { case "prepare": { assertNoUnknownFlags(ctx.args, "crm tasks prepare"); out(ctx, await callMutation(ctx, api.tasks.prepare, {})); return; } case "mine": { const rawLimit = getFlag(ctx.args, "--limit"); const priority = getFlag(ctx.args, "--priority"); const cursor = getFlag(ctx.args, "--cursor"); const paged = hasFlag(ctx.args, "--page") || cursor !== undefined; const done = hasFlag(ctx.args, "--done"); assertNoUnknownFlags(ctx.args, "crm tasks mine"); out( ctx, await callQuery( ctx, paged || done ? api.tasks.pageAssigned : api.tasks.listMyOpenTasks, { ...(paged || done ? { paginationOpts: { cursor: cursor ?? null, numItems: Number.parseInt(rawLimit ?? "25", 10), }, done, } : rawLimit ? { limit: Number.parseInt(rawLimit, 10) } : {}), ...(priority ? { priority } : {}), }, ), ); return; } case "list": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const cursor = getFlag(ctx.args, "--cursor"); const limit = getFlag(ctx.args, "--limit"); const paged = hasFlag(ctx.args, "--page") || cursor !== undefined || limit !== undefined; const done = hasFlag(ctx.args, "--done"); assertNoUnknownFlags(ctx.args, "crm tasks list"); out( ctx, await callQuery( ctx, paged || done ? api.tasks.pageEntry : api.tasks.listEntryTasks, { objectName, entryId, ...(paged || done ? { done, paginationOpts: { cursor: cursor ?? null, numItems: Number.parseInt(limit ?? "25", 10), }, } : {}), }, ), ); return; } case "create": { const objectName = shift(ctx.args, "object name"); const entryId = shift(ctx.args, "entry id"); const name = getFlag(ctx.args, "--name"); const assigneeUserId = getFlag(ctx.args, "--assignee"); const dueDateKey = getFlag(ctx.args, "--due"); const priority = getFlag(ctx.args, "--priority"); const repeatFreq = getFlag(ctx.args, "--repeat"); const repeatFlags = readRepeatFlags(ctx.args, repeatFreq); assertNoUnknownFlags(ctx.args, "crm tasks create"); if (!name?.trim()) throw new CrmCliError("--name is required"); if (dueDateKey && !/^\d{4}-\d{2}-\d{2}$/.test(dueDateKey)) { throw new CrmCliError("--due must be YYYY-MM-DD"); } // `--repeat` without `--due` is fine: the server anchors the due date // on the first day the rule matches from today. const wantsRepeat = !!(repeatFreq || repeatFlags.rrule); const repeatBody = wantsRepeat ? repeatBodyOrThrow(repeatFlags, dueDateKey) : null; const created = (await callMutation(ctx, api.tasks.createEntryTask, { objectName, entryId, name: name.trim(), ...(assigneeUserId ? { assigneeUserId } : {}), ...(dueDateKey ? { dueDateKey } : {}), ...(priority ? { priority } : {}), })) as { id: string }; if (repeatBody) { const repeat = await callMutation(ctx, api.repeat.set, { entryId: created.id, ...repeatBody, }); out(ctx, { ...created, repeat }); return; } out(ctx, created); return; } case "repeat": { // dench crm tasks repeat → show // dench crm tasks repeat weekly --on mon → set / replace // dench crm tasks repeat --rrule "FREQ=…" // dench crm tasks repeat --off | --skip | --pause | --resume const entryId = shift(ctx.args, "task entry id"); const freq = ctx.args[0]?.startsWith("--") ? undefined : ctx.args.shift(); const off = hasFlag(ctx.args, "--off"); const skip = hasFlag(ctx.args, "--skip"); const pause = hasFlag(ctx.args, "--pause"); const resume = hasFlag(ctx.args, "--resume"); const flags = readRepeatFlags(ctx.args, freq); assertNoUnknownFlags(ctx.args, "crm tasks repeat"); if ([off, skip, pause, resume].filter(Boolean).length > 1) { throw new CrmCliError( "Pass only one of --off, --skip, --pause, --resume.", ); } if (off) { out(ctx, await callMutation(ctx, api.repeat.clear, { entryId })); return; } if (skip) { out(ctx, await callMutation(ctx, api.repeat.skip, { entryId })); return; } if (pause) { out(ctx, await callMutation(ctx, api.repeat.pause, { entryId })); return; } if (resume) { out(ctx, await callMutation(ctx, api.repeat.resume, { entryId })); return; } const body = repeatBodyOrThrow(flags, null); if (Object.keys(body).length === 0) { out(ctx, await callQuery(ctx, api.repeat.get, { entryId })); return; } out(ctx, await callMutation(ctx, api.repeat.set, { entryId, ...body })); return; } case "series": { // dench crm tasks series [--status active|paused|ended] // dench crm tasks series --occurrences const maybeId = ctx.args[0]?.startsWith("--") ? undefined : ctx.args.shift(); const occurrences = hasFlag(ctx.args, "--occurrences"); const status = getFlag(ctx.args, "--status"); assertNoUnknownFlags(ctx.args, "crm tasks series"); if (maybeId && occurrences) { out( ctx, await callQuery(ctx, api.repeat.listOccurrences, { seriesId: maybeId, }), ); return; } if (maybeId) { throw new CrmCliError( "Pass --occurrences with a series id, or no id to list every series.", ); } if (status && !["active", "paused", "ended"].includes(status)) { throw new CrmCliError("--status must be active, paused or ended."); } out( ctx, await callQuery(ctx, api.repeat.listSeries, { ...(status ? { status } : {}), }), ); return; } case "subtasks": { const entryId = shift(ctx.args, "task entry id"); const cursor = getFlag(ctx.args, "--cursor"); const limit = getFlag(ctx.args, "--limit"); const paged = hasFlag(ctx.args, "--page") || cursor !== undefined || limit !== undefined; const done = hasFlag(ctx.args, "--done"); assertNoUnknownFlags(ctx.args, "crm tasks subtasks"); out( ctx, await callQuery( ctx, paged || done ? api.tasks.pageEntry : api.tasks.listSubtasks, { entryId, ...(paged || done ? { objectName: "task", done, paginationOpts: { cursor: cursor ?? null, numItems: Number.parseInt(limit ?? "25", 10), }, } : {}), }, ), ); return; } case "create-subtask": { const parentEntryId = shift(ctx.args, "parent task entry id"); const name = getFlag(ctx.args, "--name"); const assigneeUserId = getFlag(ctx.args, "--assignee"); const dueDateKey = getFlag(ctx.args, "--due"); const priority = getFlag(ctx.args, "--priority"); assertNoUnknownFlags(ctx.args, "crm tasks create-subtask"); if (!name?.trim()) throw new CrmCliError("--name is required"); if (dueDateKey && !/^\d{4}-\d{2}-\d{2}$/.test(dueDateKey)) { throw new CrmCliError("--due must be YYYY-MM-DD"); } out( ctx, await callMutation(ctx, api.tasks.createSubtask, { parentEntryId, name: name.trim(), ...(assigneeUserId ? { assigneeUserId } : {}), ...(dueDateKey ? { dueDateKey } : {}), ...(priority ? { priority } : {}), }), ); return; } case "parent": { // `dench crm tasks parent ` / `… --none` to promote. const entryId = shift(ctx.args, "task entry id"); const none = hasFlag(ctx.args, "--none"); const parentEntryId = none ? null : ctx.args.shift(); assertNoUnknownFlags(ctx.args, "crm tasks parent"); if (!none && !parentEntryId?.trim()) { throw new CrmCliError( "Pass the parent task id, or --none to make it a top-level task.", ); } out( ctx, await callMutation(ctx, api.tasks.setTaskParent, { entryId, parentEntryId: parentEntryId?.trim() ?? null, }), ); return; } case "delete": { const entryId = shift(ctx.args, "task entry id"); const keep = hasFlag(ctx.args, "--keep-subtasks"); assertNoUnknownFlags(ctx.args, "crm tasks delete"); out( ctx, await callAction(ctx, api.tasks.deleteTask, { entryId, subtasks: keep ? "keep" : "delete", }), ); return; } case "priority": { // `dench crm tasks priority high` / `… --none` to clear. const entryId = shift(ctx.args, "task entry id"); const clear = hasFlag(ctx.args, "--none"); const value = clear ? null : ctx.args.shift(); assertNoUnknownFlags(ctx.args, "crm tasks priority"); if (!clear && !value?.trim()) { throw new CrmCliError( "Pass a priority (e.g. high) or --none to clear it.", ); } out( ctx, await callMutation(ctx, api.tasks.setTaskPriority, { entryId, priority: value?.trim() ?? null, }), ); return; } case "complete": case "reopen": { const entryId = shift(ctx.args, "task entry id"); assertNoUnknownFlags(ctx.args, `crm tasks ${verb}`); out( ctx, await callMutation( ctx, verb === "complete" ? api.tasks.completeTask : api.tasks.reopenTask, { entryId }, ), ); return; } default: throw new CrmCliError( `Unknown tasks verb: ${verb ?? "(none)"}. Use mine | list | create | subtasks | create-subtask | parent | priority | repeat | series | complete | reopen | delete.`, ); } }