@wirecat/cli-core 0.596.0 → 0.597.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/dist/background/deliver.d.ts +7 -1
  3. package/dist/background/deliver.js +66 -10
  4. package/dist/background/telegram-html.d.ts +2 -0
  5. package/dist/background/telegram-html.js +66 -0
  6. package/dist/calendar/mcp/index.d.ts +12 -12
  7. package/dist/cli/index.d.ts +1 -1
  8. package/dist/cli/permission-keys.d.ts +10 -2
  9. package/dist/cli/preview.d.ts +3 -0
  10. package/dist/cli/preview.js +9 -5
  11. package/dist/crm/cli/index.js +71 -14
  12. package/dist/crm/index.d.ts +1 -1
  13. package/dist/crm/index.js +1 -1
  14. package/dist/crm/source.d.ts +6 -1
  15. package/dist/crm/sync.d.ts +5 -4
  16. package/dist/crm/sync.js +22 -4
  17. package/dist/crm/testing/index.d.ts +1 -1
  18. package/dist/crm/testing/index.js +1 -1
  19. package/dist/crm/testing/source-contract.js +28 -0
  20. package/dist/crm/testing/source.d.ts +8 -1
  21. package/dist/crm/testing/source.js +53 -9
  22. package/dist/crm/testing/sync-contract.js +14 -3
  23. package/dist/crm/writer.d.ts +11 -2
  24. package/dist/crm/writer.js +31 -0
  25. package/dist/crm/writes.d.ts +12 -2
  26. package/dist/crm/writes.js +93 -8
  27. package/dist/domain/crm.d.ts +29 -1
  28. package/dist/entities/deal.d.ts +16 -0
  29. package/dist/entities/deal.js +8 -0
  30. package/dist/entities/index.d.ts +12 -0
  31. package/dist/errors/reasons.js +4 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/meetings/mcp/index.d.ts +15 -15
  34. package/dist/store/driver.d.ts +2 -0
  35. package/dist/store/driver.js +2 -0
  36. package/dist/store/search-index.js +6 -1
  37. package/dist/store/sqlite/accounts.js +1 -0
  38. package/dist/store/sqlite/crm.d.ts +5 -1
  39. package/dist/store/sqlite/crm.js +52 -0
  40. package/dist/store/sqlite/deals.d.ts +5 -0
  41. package/dist/store/sqlite/deals.js +13 -0
  42. package/dist/store/sqlite/manifest.js +1 -0
  43. package/dist/store/sqlite/migrations.generated.js +6 -0
  44. package/dist/store/sqlite/note-index.d.ts +1 -1
  45. package/dist/store/sqlite/note-index.js +3 -0
  46. package/dist/store/sqlite/schema.d.ts +217 -0
  47. package/dist/store/sqlite/schema.js +18 -0
  48. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,56 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
5
5
  minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
6
6
  shape of this file.
7
7
 
8
+ ## Unreleased
9
+
10
+ ## 0.597.0 — 07.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`--field` by label.** CRM `create` and `update` resolve `--field <key>=<value>` by the synced field definitions:
15
+ a field by its code or whole label (a label two fields share is refused as `field.ambiguous` with both codes, an
16
+ unknown one as `field.unknown` with close candidates), a list field's option by its id or label
17
+ (`field.unknown-option`), and a multiple field's repeats into a list; a single field given twice is refused. A CRM
18
+ whose definitions are not stored takes codes as typed.
19
+ - **`CrmWrites.alsoChecks(kind, verb)`**: a CLI's own keys a write also checks, such as `api.crm.deal.update`.
20
+ `crmAlsoChecks(also)` gives `commandPermissions({ alsoChecks })` every key a CRM write may check, so `--dry-run`
21
+ names the implied move, close and `crm.assign-others` keys and the CLI's own. A key an argument implies is a
22
+ `ConditionalKey` `{ key, when }`: `commandPermissions({ alsoChecks })` may return one, and the preview shows its level
23
+ and `when` but never refuses on it.
24
+ - **A deal's and lead's `provider_metadata` and `assignee`.** The CRM's own field values, by code as
25
+ `{ label, value, option_id? }` (`CrmValue`), kept as stored in `metadata`, codes never renamed; `CrmItem.assignee`, the
26
+ CRM user it is assigned to, answered as the identity `<account>/user/<id>`.
27
+ - **CRM field definitions (store version 54).** `CrmSource.fields?(kind)` lists a CRM's own fields on deals, leads,
28
+ contacts or companies: `CrmField` `{ code, label, type, multiple, filterable, options? }`. `syncCrm` reads them every
29
+ run, after the pipelines, and `store.crm.saveFields` keeps each kind's whole list in `crm_fields`: one the CRM no
30
+ longer lists is marked deleted, and an empty list marks them all. `store.crm.fields(account, kind)` reads them in
31
+ the CRM's order. Values stay in `metadata`, keyed by code. The migration adds one table; `minCompatible` stays 34.
32
+ - **`fields list <deals|leads|people|organizations>`** in `addCrmCommands`: the synced fields of one kind, with
33
+ `--limit`, `--page` and `--all`, in bitrix24-cli's `fields list` shape plus `untrusted`. An unknown kind is refused
34
+ as `field.unknown-kind` with the allowed words.
35
+ - **`crmSourceContract` checks `fields()`** when an adapter has it, and `crmSyncContract` checks they are stored. The
36
+ fake CRM has `setFields`, `calls.fields` and `SOURCE_FIELD` on its deals.
37
+ - **A schedule's Telegram delivery is Telegram HTML.** `--to telegram-bot:<profile>/<chat>` converts the report's
38
+ Markdown (headings, bold, italic, code, links, lists, tables as label lines, rules) to the tags `tg` accepts and
39
+ sends it with `--html`; the 4096 and 1024 character limits count the converted text, and a long report's caption
40
+ is cut until its HTML fits. When `tg` answers `validation_error`, the send is repeated once as plain text and
41
+ recorded as `delivery.html-fallback`; any other failure is never resent. Email stays plain text.
42
+
43
+ ### Changed — may break callers
44
+
45
+ - **`CrmFieldsInput.custom` is the `--field` pairs as typed** (`[name, value][]`), and `CrmFields.custom` values may be
46
+ a list for a multiple field.
47
+ - **A write the CRM made whose item could not be read back is journaled as sent.** A `CrmWriter` throws it with
48
+ `sent: true` in the details; the error is answered with the write's `operation_id`, no longer journaled as failed.
49
+ - **A CRM sync with `fields()` makes one more request per kind**, so a `--max-pages` budget reaches the items later.
50
+
51
+ ### Fixed
52
+
53
+ - **A search over a read-only store no longer fails with "attempt to write a readonly database".** The message index
54
+ top-up and the notes, mail, events, memories and tasks index drains are skipped on a connection with `query_only`
55
+ (every read-only open), so `search all` in a CRM CLI answers from every source of a store only a CRM sync wrote. They
56
+ run on the next writable open.
57
+
8
58
  ## 0.596.0 — 06.11.2026
9
59
 
10
60
  ### Added
@@ -22,9 +22,13 @@ export declare const DELIVERY_REASONS: {
22
22
  readonly routeInvalid: "delivery.route-invalid";
23
23
  readonly timeout: "delivery.timeout";
24
24
  readonly failed: "delivery.failed";
25
+ readonly htmlFallback: "delivery.html-fallback";
25
26
  };
26
27
  export type DeliveryReason = (typeof DELIVERY_REASONS)[keyof typeof DELIVERY_REASONS];
27
- /** One route's outcome, kept in `last.json`. `error` is the sending CLI's error code and reason id, never its message. */
28
+ /**
29
+ * One route's outcome, kept in `last.json`. `error` is the sending CLI's error code and reason id, never its message.
30
+ * A send that went as plain text because Telegram refused the HTML is `ok` with the `html-fallback` reason.
31
+ */
28
32
  export interface Delivery {
29
33
  to: string;
30
34
  ok: boolean;
@@ -46,6 +50,8 @@ export interface Parcel {
46
50
  /** A failed run: one line, no file. */
47
51
  line?: string;
48
52
  }
53
+ /** The caption as HTML: the head is cut in Markdown, where no tag can be split, and cut shorter until its HTML fits. */
54
+ export declare const htmlHeadOf: (title: string, report: string, limit?: number) => string;
49
55
  /** Hands the parcel to each route's CLI, one after another; a route that fails is recorded and the next one still goes. */
50
56
  export declare const deliver: (system: ServerSystem, env: NodeJS.ProcessEnv, routes: readonly string[], parcel: Parcel, { find, errorOf, locked, timeoutMs, }: {
51
57
  /** Where a program is on the run's PATH. */
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { CliError } from "@wirecat/cli-base";
5
5
  import { TIMED_OUT } from "./system.js";
6
+ import { toTelegramHtml } from "./telegram-html.js";
6
7
  const PROGRAMS = {
7
8
  "telegram-bot": { command: "tg", prefix: "TG" },
8
9
  email: { command: "mail", prefix: "MAIL" },
@@ -36,6 +37,7 @@ export const DELIVERY_REASONS = {
36
37
  routeInvalid: "delivery.route-invalid",
37
38
  timeout: "delivery.timeout",
38
39
  failed: "delivery.failed",
40
+ htmlFallback: "delivery.html-fallback",
39
41
  };
40
42
  /** Telegram's limits, in UTF-16 units as JavaScript counts them: a message's text, and a file's caption. */
41
43
  export const TELEGRAM_TEXT_CHARS = 4096;
@@ -52,17 +54,61 @@ export const headOf = (title, report, limit = CAPTION_CHARS) => {
52
54
  const line = cut.lastIndexOf("\n");
53
55
  return `${title}\n${line > room / 2 ? cut.slice(0, line) : cut}${more}`;
54
56
  };
57
+ /**
58
+ * `tg` answers a 400 from Telegram, which is what a refused HTML text is, as `validation_error`, and says nothing
59
+ * more exact. Any other code (network_error, outcome_unknown, provider_error…) may mean the message arrived.
60
+ */
61
+ const REFUSED_CONTENT = "validation_error";
62
+ const codeOf = (stderr) => {
63
+ for (const line of stderr.split("\n").reverse()) {
64
+ try {
65
+ const { code } = JSON.parse(line).error ?? {};
66
+ if (typeof code === "string")
67
+ return code;
68
+ }
69
+ catch { }
70
+ }
71
+ };
72
+ const textOf = (title, report) => (report.trim() ? `${title}\n${report.trim()}` : title);
73
+ /** The same text as Telegram HTML; the length that counts against the limits is this one. */
74
+ const htmlOf = (title, report) => toTelegramHtml(textOf(title, report));
75
+ /** The caption as HTML: the head is cut in Markdown, where no tag can be split, and cut shorter until its HTML fits. */
76
+ export const htmlHeadOf = (title, report, limit = CAPTION_CHARS) => {
77
+ let room = limit;
78
+ for (;;) {
79
+ const html = toTelegramHtml(headOf(title, report, room));
80
+ if (html.length <= limit || room <= title.length + 40)
81
+ return html.slice(0, limit);
82
+ room -= Math.max(html.length - limit, 1);
83
+ }
84
+ };
55
85
  /**
56
86
  * The words that send along `route`. A bot sends a report that fits one message as its text, and a longer one as a
57
- * file with its head as the caption; mail takes the text on stdin. The text goes after `--`, so a report that starts
58
- * with a dash stays text.
87
+ * file with its head as the caption, both as Telegram HTML unless `plain`; mail takes the text on stdin. The text goes
88
+ * after `--`, so a report that starts with a dash stays text.
59
89
  */
60
- const argvOf = (program, route, title, report, copy) => {
90
+ const argvOf = (program, route, title, report, copy, plain = false) => {
61
91
  const lead = [program, route.profile, "--json", "--no-input"];
62
92
  if (route.kind === "email")
63
93
  return [...lead, "messages", "send", "--to", route.address, "--subject", title];
64
- const text = copy ? headOf(title, report) : report.trim() ? `${title}\n${report.trim()}` : title;
65
- return [...lead, "bot", "messages", "send", ...(copy ? ["--file", copy] : []), "--", route.chat, text];
94
+ const text = plain
95
+ ? copy
96
+ ? headOf(title, report)
97
+ : headOf(title, report, TELEGRAM_TEXT_CHARS)
98
+ : copy
99
+ ? htmlHeadOf(title, report)
100
+ : htmlOf(title, report);
101
+ return [
102
+ ...lead,
103
+ "bot",
104
+ "messages",
105
+ "send",
106
+ ...(copy ? ["--file", copy] : []),
107
+ ...(plain ? [] : ["--html"]),
108
+ "--",
109
+ route.chat,
110
+ text,
111
+ ];
66
112
  };
67
113
  /** Hands the parcel to each route's CLI, one after another; a route that fails is recorded and the next one still goes. */
68
114
  export const deliver = async (system, env, routes, parcel, { find, errorOf, locked, timeoutMs = DELIVERY_TIMEOUT_MS, }) => {
@@ -94,7 +140,7 @@ export const deliver = async (system, env, routes, parcel, { find, errorOf, lock
94
140
  out.push(failed(to, DELIVERY_REASONS.programMissing, command));
95
141
  continue;
96
142
  }
97
- const tooLong = `${parcel.title}\n${report.trim()}`.length > TELEGRAM_TEXT_CHARS;
143
+ const tooLong = route.kind === "telegram-bot" && htmlOf(parcel.title, report).length > TELEGRAM_TEXT_CHARS;
98
144
  // The sending CLI refuses its own and the other WireCat folders, where the report lives, so it gets a copy.
99
145
  const folder = route.kind === "telegram-bot" && parcel.file && tooLong ? mkdtempSync(join(tmpdir(), "wirecat-deliver-")) : "";
100
146
  const copy = folder && parcel.file ? join(folder, parcel.file.name) : undefined;
@@ -102,12 +148,22 @@ export const deliver = async (system, env, routes, parcel, { find, errorOf, lock
102
148
  if (copy && parcel.file)
103
149
  copyFileSync(parcel.file.path, copy);
104
150
  const { [`${prefix}_PROFILE`]: _, ...childEnv } = env;
105
- const ran = await system.run(argvOf(program, route, parcel.title, report, copy), childEnv, {
151
+ const options = {
106
152
  timeoutMs,
107
153
  ...(route.kind === "email" ? { input: report.trim() ? report : parcel.title } : {}),
108
- });
109
- if (ran.code === 0)
110
- out.push({ to, ok: true });
154
+ };
155
+ let ran = await system.run(argvOf(program, route, parcel.title, report, copy), childEnv, options);
156
+ let fellBack = "";
157
+ if (route.kind === "telegram-bot" &&
158
+ ran.code !== 0 &&
159
+ ran.code !== TIMED_OUT &&
160
+ codeOf(ran.stderr) === REFUSED_CONTENT) {
161
+ fellBack = errorOf(ran.stderr, ran.code);
162
+ ran = await system.run(argvOf(program, route, parcel.title, report, copy, true), childEnv, options);
163
+ }
164
+ if (ran.code === 0) {
165
+ out.push(fellBack ? { to, ok: true, reason: DELIVERY_REASONS.htmlFallback, error: fellBack } : { to, ok: true });
166
+ }
111
167
  else if (ran.code === TIMED_OUT)
112
168
  out.push(failed(to, DELIVERY_REASONS.timeout));
113
169
  else
@@ -0,0 +1,2 @@
1
+ /** The report's common Markdown as Telegram HTML (<b>, <i>, <a>, <code>, <pre>); everything else is escaped text. */
2
+ export declare const toTelegramHtml: (markdown: string) => string;
@@ -0,0 +1,66 @@
1
+ const esc = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
2
+ const SLOT = "\u0000";
3
+ /** Inline Markdown to the tags Telegram's HTML accepts; code and links are set aside first so nothing inside them is touched. */
4
+ const inline = (text) => {
5
+ const kept = [];
6
+ const keep = (html) => `${SLOT}${kept.push(html) - 1}${SLOT}`;
7
+ const done = esc(text.replaceAll(SLOT, ""))
8
+ .replace(/`([^`\n]+)`/g, (_, code) => keep(`<code>${code}</code>`))
9
+ .replace(/\[([^\]\n]+)\]\((https?:\/\/[^\s)"]+)\)/g, (_, label, url) => keep(`<a href="${url}">${label}</a>`))
10
+ .replace(/\*\*(?=\S)(.+?)(?<=\S)\*\*|__(?=\S)(.+?)(?<=\S)__/g, (_, a, b) => `<b>${a ?? b}</b>`)
11
+ .replace(/(?<![\w*])\*(?=[^\s*])([^*\n]+?)(?<=[^\s*])\*(?![\w*])/g, "<i>$1</i>")
12
+ .replace(/(?<!\w)_(?=[^\s_])([^_\n]+?)(?<=[^\s_])_(?!\w)/g, "<i>$1</i>");
13
+ return done.replace(new RegExp(`${SLOT}(\\d+)${SLOT}`, "g"), (_, at) => kept[Number(at)] ?? "");
14
+ };
15
+ const cells = (row) => row
16
+ .trim()
17
+ .replace(/^\||\|$/g, "")
18
+ .split("|")
19
+ .map((cell) => cell.trim());
20
+ const isRule = (row) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(row);
21
+ /** A table has no Telegram form: each row becomes a bullet, its first cell the label and the rest `header: value`. */
22
+ const tableLines = (rows) => {
23
+ const [head = [], ...body] = rows.filter((row) => !isRule(row)).map(cells);
24
+ return body.map(([first = "", ...rest]) => {
25
+ const parts = rest.map((cell, at) => {
26
+ const name = head[at + 1];
27
+ return name && rest.length > 1 ? `${name}: ${inline(cell)}` : inline(cell);
28
+ });
29
+ return `• ${parts.length ? `<b>${inline(first)}</b>: ${parts.join(" · ")}` : inline(first)}`;
30
+ });
31
+ };
32
+ /** The report's common Markdown as Telegram HTML (<b>, <i>, <a>, <code>, <pre>); everything else is escaped text. */
33
+ export const toTelegramHtml = (markdown) => {
34
+ const out = [];
35
+ const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
36
+ for (let at = 0; at < lines.length; at += 1) {
37
+ const line = lines[at] ?? "";
38
+ if (/^\s*```/.test(line)) {
39
+ const code = [];
40
+ at += 1;
41
+ while (at < lines.length && !/^\s*```/.test(lines[at] ?? ""))
42
+ code.push(lines[at++] ?? "");
43
+ out.push(`<pre>${esc(code.join("\n"))}</pre>`);
44
+ }
45
+ else if (/^\s*\|.*\|\s*$/.test(line)) {
46
+ const rows = [line];
47
+ while (/^\s*\|.*\|\s*$/.test(lines[at + 1] ?? "")) {
48
+ at += 1;
49
+ rows.push(lines[at] ?? "");
50
+ }
51
+ for (const row of tableLines(rows))
52
+ out.push(row);
53
+ }
54
+ else if (/^#{1,6}\s+\S/.test(line)) {
55
+ out.push(`<b>${inline(line.replace(/^#{1,6}\s+/, "").replace(/\*\*|__/g, "")).trimEnd()}</b>`);
56
+ }
57
+ else if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
58
+ out.push("──────");
59
+ }
60
+ else {
61
+ const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
62
+ out.push(bullet ? `${bullet[1]}• ${inline(bullet[2] ?? "")}` : inline(line));
63
+ }
64
+ }
65
+ return out.join("\n");
66
+ };
@@ -10,13 +10,13 @@ export declare const calendarTools: ({
10
10
  inputSchema: {
11
11
  type: string;
12
12
  properties: {
13
- limit?: undefined;
14
13
  account_id: {
15
14
  type: string;
16
15
  minimum: number;
17
16
  };
18
17
  query?: undefined;
19
18
  timezone?: undefined;
19
+ limit?: undefined;
20
20
  page?: undefined;
21
21
  event?: undefined;
22
22
  } | {
@@ -45,9 +45,9 @@ export declare const calendarTools: ({
45
45
  };
46
46
  event?: undefined;
47
47
  } | {
48
- limit?: undefined;
49
48
  query?: undefined;
50
49
  timezone?: undefined;
50
+ limit?: undefined;
51
51
  page?: undefined;
52
52
  event: {
53
53
  type: string;
@@ -58,9 +58,9 @@ export declare const calendarTools: ({
58
58
  minimum: number;
59
59
  };
60
60
  } | {
61
- limit?: undefined;
62
61
  query?: undefined;
63
62
  timezone?: undefined;
63
+ limit?: undefined;
64
64
  page?: undefined;
65
65
  event?: undefined;
66
66
  account_id?: undefined;
@@ -80,13 +80,13 @@ export declare const calendarTools: ({
80
80
  inputSchema: {
81
81
  type: string;
82
82
  properties: {
83
- limit?: undefined;
84
83
  account_id: {
85
84
  type: string;
86
85
  minimum: number;
87
86
  };
88
87
  query?: undefined;
89
88
  timezone?: undefined;
89
+ limit?: undefined;
90
90
  page?: undefined;
91
91
  event?: undefined;
92
92
  } | {
@@ -115,9 +115,9 @@ export declare const calendarTools: ({
115
115
  };
116
116
  event?: undefined;
117
117
  } | {
118
- limit?: undefined;
119
118
  query?: undefined;
120
119
  timezone?: undefined;
120
+ limit?: undefined;
121
121
  page?: undefined;
122
122
  event: {
123
123
  type: string;
@@ -128,9 +128,9 @@ export declare const calendarTools: ({
128
128
  minimum: number;
129
129
  };
130
130
  } | {
131
- limit?: undefined;
132
131
  query?: undefined;
133
132
  timezone?: undefined;
133
+ limit?: undefined;
134
134
  page?: undefined;
135
135
  event?: undefined;
136
136
  account_id?: undefined;
@@ -150,13 +150,13 @@ export declare const calendarTools: ({
150
150
  inputSchema: {
151
151
  type: string;
152
152
  properties: {
153
- limit?: undefined;
154
153
  account_id: {
155
154
  type: string;
156
155
  minimum: number;
157
156
  };
158
157
  query?: undefined;
159
158
  timezone?: undefined;
159
+ limit?: undefined;
160
160
  page?: undefined;
161
161
  event?: undefined;
162
162
  } | {
@@ -185,9 +185,9 @@ export declare const calendarTools: ({
185
185
  };
186
186
  event?: undefined;
187
187
  } | {
188
- limit?: undefined;
189
188
  query?: undefined;
190
189
  timezone?: undefined;
190
+ limit?: undefined;
191
191
  page?: undefined;
192
192
  event: {
193
193
  type: string;
@@ -198,9 +198,9 @@ export declare const calendarTools: ({
198
198
  minimum: number;
199
199
  };
200
200
  } | {
201
- limit?: undefined;
202
201
  query?: undefined;
203
202
  timezone?: undefined;
203
+ limit?: undefined;
204
204
  page?: undefined;
205
205
  event?: undefined;
206
206
  account_id?: undefined;
@@ -220,13 +220,13 @@ export declare const calendarTools: ({
220
220
  inputSchema: {
221
221
  type: string;
222
222
  properties: {
223
- limit?: undefined;
224
223
  account_id: {
225
224
  type: string;
226
225
  minimum: number;
227
226
  };
228
227
  query?: undefined;
229
228
  timezone?: undefined;
229
+ limit?: undefined;
230
230
  page?: undefined;
231
231
  event?: undefined;
232
232
  } | {
@@ -255,9 +255,9 @@ export declare const calendarTools: ({
255
255
  };
256
256
  event?: undefined;
257
257
  } | {
258
- limit?: undefined;
259
258
  query?: undefined;
260
259
  timezone?: undefined;
260
+ limit?: undefined;
261
261
  page?: undefined;
262
262
  event: {
263
263
  type: string;
@@ -268,9 +268,9 @@ export declare const calendarTools: ({
268
268
  minimum: number;
269
269
  };
270
270
  } | {
271
- limit?: undefined;
272
271
  query?: undefined;
273
272
  timezone?: undefined;
273
+ limit?: undefined;
274
274
  page?: undefined;
275
275
  event?: undefined;
276
276
  account_id?: undefined;
@@ -115,7 +115,7 @@ export { type Saving, stored } from "./messenger/stored.js";
115
115
  export { dateOrNone } from "./messenger/tasks-command.js";
116
116
  export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
117
117
  export { positiveCount, window, withPaging } from "./paging.js";
118
- export { type CommandPermissions, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
118
+ export { type CommandPermissions, type ConditionalKey, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
119
119
  export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
120
120
  export { createProgram, type ProgramDefinition, type ProgramOptions, type RunOptions, run } from "./program.js";
121
121
  export { type ProjectTextHints, projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf, withSyncCommands, } from "./project-evidence.js";
@@ -14,8 +14,16 @@ export interface CommandPermissions {
14
14
  extra: readonly PermissionKey[];
15
15
  /** cli-core's built-in levels and the CLI's own; they only ever tighten. */
16
16
  defaults: Readonly<Record<PermissionKey, Level>>;
17
- /** The other keys a command's real run checks, so its `--dry-run` preview shows each: gcal's `api.calendar.*`. */
18
- alsoChecks?: (path: readonly string[]) => readonly PermissionKey[];
17
+ /**
18
+ * The other keys a command's real run checks, so its `--dry-run` preview shows each: gcal's `api.calendar.*`. A
19
+ * key with `when` is checked only for some arguments, such as a stage that closes a deal: shown, never refused.
20
+ */
21
+ alsoChecks?: (path: readonly string[]) => readonly (PermissionKey | ConditionalKey)[];
22
+ }
23
+ /** A key a write checks only when its arguments ask for it; `when` says which, in a few words. */
24
+ export interface ConditionalKey {
25
+ key: PermissionKey;
26
+ when: string;
19
27
  }
20
28
  /** Either way of naming a program's keys: a messenger's per-command mapping, or a declared CLI's own. */
21
29
  export type PermissionKeySource = PermissionKeyOf | CommandPermissions;
@@ -13,6 +13,7 @@ export declare const preview: (command: Command, settings: Pick<Settings, "permi
13
13
  also?: {
14
14
  key: PermissionKey;
15
15
  level: string;
16
+ when?: string;
16
17
  }[];
17
18
  } | undefined;
18
19
  validation: {
@@ -49,6 +50,7 @@ export declare const previewBody: (command: Command, permission: {
49
50
  also?: {
50
51
  key: PermissionKey;
51
52
  level: string;
53
+ when?: string;
52
54
  }[];
53
55
  } | undefined, messenger: boolean) => {
54
56
  preview: boolean;
@@ -59,6 +61,7 @@ export declare const previewBody: (command: Command, permission: {
59
61
  also?: {
60
62
  key: PermissionKey;
61
63
  level: string;
64
+ when?: string;
62
65
  }[];
63
66
  } | undefined;
64
67
  validation: {
@@ -23,11 +23,15 @@ export const preview = (command, settings, tool, keys, messenger = true) => {
23
23
  (!declared && key !== null && key !== undefined && WRITE_KEYS.includes(key));
24
24
  if (level === "deny" || (writes && level === "readonly"))
25
25
  throw new CliError("permission_error", `this profile does not permit ${path.join(" ")}`, { permission: key });
26
- const also = (declared?.alsoChecks?.(path) ?? []).map((one) => ({
27
- key: one,
28
- level: levelFor(permissions, one, declared?.defaults).level,
29
- }));
30
- const stopped = also.find((one) => one.level === "deny" || (writes && one.level === "readonly"));
26
+ const also = (declared?.alsoChecks?.(path) ?? []).map((one) => {
27
+ const key = typeof one === "string" ? one : one.key;
28
+ return {
29
+ key,
30
+ level: levelFor(permissions, key, declared?.defaults).level,
31
+ ...(typeof one === "string" ? {} : { when: one.when }),
32
+ };
33
+ });
34
+ const stopped = also.find((one) => one.when === undefined && (one.level === "deny" || (writes && one.level === "readonly")));
31
35
  if (stopped)
32
36
  throw new CliError("permission_error", `this profile does not permit ${stopped.key}`, { permission: stopped.key });
33
37
  return previewBody(command, { key: key ?? null, level, ...(also.length ? { also } : {}) }, messenger);
@@ -6,8 +6,8 @@ import { answerEntity } from "../../cli/answer-entity.js";
6
6
  import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
7
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
8
  import { FETCH, LOCAL_READ, NO_EFFECTS, REMOTE_WRITE } from "../../cli/effects.js";
9
- import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
10
- import { queryTerms, termField, withQuery } from "../../cli/list-query.js";
9
+ import { itemOf, listOf, renderAnswer, snakeKeys, snakeKeysKeeping } from "../../cli/envelope.js";
10
+ import { queryTerms, termField, unfilteredQuery, withQuery } from "../../cli/list-query.js";
11
11
  import { terminalAsker } from "../../cli/messenger/ask.js";
12
12
  import { lineOf } from "../../cli/steps.js";
13
13
  import { CRM_KINDS } from "../../domain/crm.js";
@@ -41,7 +41,15 @@ export const STAGES_QUERY = {
41
41
  ],
42
42
  };
43
43
  /** A CRM's users typed these; a list item names its own, as `events list` does. */
44
- const DEAL_UNTRUSTED = ["title", "summary", "stage", "pipeline_name", "organization.name", "people.name"];
44
+ const DEAL_UNTRUSTED = [
45
+ "title",
46
+ "summary",
47
+ "stage",
48
+ "pipeline_name",
49
+ "organization.name",
50
+ "people.name",
51
+ "provider_metadata",
52
+ ];
45
53
  const PIPELINE_UNTRUSTED = ["name", "stages.name"];
46
54
  const STAGE_UNTRUSTED = ["name", "pipeline_name"];
47
55
  const crmSync = v.strictObject({
@@ -92,18 +100,36 @@ const crmWrite = v.strictObject({
92
100
  lead: v.optional(dealEntity),
93
101
  untrusted: v.array(text),
94
102
  });
103
+ /** b24's `fields list` answer, so a CRM CLI's own command can go; `untrusted` names what the CRM's users typed. */
104
+ const crmField = v.strictObject({
105
+ code: v.string(),
106
+ label: v.nullable(v.string()),
107
+ type: v.string(),
108
+ multiple: v.boolean(),
109
+ filterable: v.boolean(),
110
+ options: v.optional(v.array(v.strictObject({ id: v.string(), label: v.string() }))),
111
+ untrusted: v.array(text),
112
+ });
113
+ const FIELDS_QUERY = unfilteredQuery("crm_field", crmField);
114
+ const FIELD_UNTRUSTED = ["label", "options.label"];
115
+ /** The words `fields list` takes: its sibling commands' nouns. */
116
+ const FIELD_KINDS = {
117
+ deals: "deal",
118
+ leads: "lead",
119
+ people: "person",
120
+ organizations: "organization",
121
+ };
95
122
  const crmRemoval = v.strictObject({ operation_id: text, ref: text, removed: count });
96
123
  const collect = (value, previous = []) => {
97
124
  if (value.indexOf("=") < 1)
98
- throw new InvalidArgumentError("expected code=value");
125
+ throw new InvalidArgumentError("expected key=value");
99
126
  return [...previous, value];
100
127
  };
101
- /** `--field code=value` pairs; an empty value clears the field. */
102
- const customOf = (pairs) => Object.fromEntries(pairs.map((pair) => {
128
+ /** `--field` as typed, split at the first `=`. */
129
+ const pairsOf = (typed) => typed.map((pair) => {
103
130
  const at = pair.indexOf("=");
104
- const given = pair.slice(at + 1);
105
- return [pair.slice(0, at), given === "" ? null : given];
106
- }));
131
+ return [pair.slice(0, at), pair.slice(at + 1)];
132
+ });
107
133
  const integer = (most) => (value) => {
108
134
  if (!/^[1-9]\d*$/.test(value) || Number(value) > most)
109
135
  throw new InvalidArgumentError(`expected 1 to ${most}`);
@@ -111,7 +137,9 @@ const integer = (most) => (value) => {
111
137
  };
112
138
  /** The words below the program, as `page.next` and `sync`'s next step run it again. */
113
139
  const pathOf = (command) => command.parent?.parent ? [...pathOf(command.parent), command.name()] : [command.name()];
114
- const marked = (items, untrusted) => items.map((item) => ({ ...snakeKeys(item), untrusted }));
140
+ /** A CRM's own field codes are its keys, never ours to rename. */
141
+ const dealKeys = snakeKeysKeeping(["providerMetadata"]);
142
+ const marked = (items, untrusted) => items.map((item) => ({ ...dealKeys(item), untrusted }));
115
143
  const addCrmCommandsDefinition = (program, options) => {
116
144
  const now = () => options.now?.() ?? Date.now();
117
145
  const within = async (command, work) => {
@@ -237,7 +265,7 @@ const addCrmCommandsDefinition = (program, options) => {
237
265
  .argument("<ref>", `${kind}:<id>, as a list shows it`), `${plural} show ${kind}:12`).action(async (reference, _opts, command) => {
238
266
  await within(command, async (run) => {
239
267
  const found = await ownCopy(run, kind, reference);
240
- return itemOf({ ...snakeKeys(found), untrusted: DEAL_UNTRUSTED });
268
+ return itemOf({ ...dealKeys(found), untrusted: DEAL_UNTRUSTED });
241
269
  });
242
270
  });
243
271
  addWrites(group, kind);
@@ -272,7 +300,7 @@ const addCrmCommandsDefinition = (program, options) => {
272
300
  };
273
301
  const answered = (operationId, item) => itemOf({
274
302
  operation_id: operationId,
275
- [kind]: { ...snakeKeys(item), untrusted: DEAL_UNTRUSTED },
303
+ [kind]: { ...dealKeys(item), untrusted: DEAL_UNTRUSTED },
276
304
  untrusted: DEAL_UNTRUSTED.map((field) => `${kind}.${field}`),
277
305
  });
278
306
  const write = (name, verb, description) => {
@@ -292,12 +320,14 @@ const addCrmCommandsDefinition = (program, options) => {
292
320
  .option("--currency <code>", "an ISO 4217 code, such as EUR")
293
321
  .option("--close-date <date>", "the day it is expected to close, YYYY-MM-DD")
294
322
  .option("--owner <user>", "the CRM user it is assigned to; anyone but the sign-in's own user checks crm.assign-others")
295
- .option("--field <key=value>", "one of the CRM's own fields, by its code; repeat for more, empty clears it", collect,
323
+ .option("--field <key=value>", "one of the CRM's own fields, by its code or label as `fields list` prints them, and its value; a list " +
324
+ "field takes its option's label or id; repeat it for more fields, or for each value of a multiple field; " +
325
+ "an empty value clears it", collect,
296
326
  // The empty default marks the flag as repeatable, so an agent's tool takes a list.
297
327
  []);
298
328
  const inputOf = ({ field, operationId: _id, ...rest }) => ({
299
329
  ...Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined)),
300
- ...(field?.length ? { custom: customOf(field) } : {}),
330
+ ...(field?.length ? { custom: pairsOf(field) } : {}),
301
331
  });
302
332
  const idOf = (opts) => opts.operationId === undefined ? {} : { operationId: opts.operationId };
303
333
  const run = (command, work) => within(command, (opened) => work(contextOf(opened, command)));
@@ -408,6 +438,33 @@ const addCrmCommandsDefinition = (program, options) => {
408
438
  });
409
439
  });
410
440
  });
441
+ const fieldKinds = Object.keys(FIELD_KINDS).filter((word) => word !== "leads" || options.leads !== false);
442
+ const fields = annotate(program.command("fields"), { effects: NO_EFFECTS }).description("the CRM's own fields on deals, leads, people and organizations, as last synced");
443
+ example(withQuery(answerEntity(annotate(fields.command("list"), { effects: LOCAL_READ }), "crm_field", crmField)
444
+ .description("the CRM's custom fields of one kind, in its order, with a list field's options")
445
+ .argument("<kind>", fieldKinds.join(", "))
446
+ .option("--limit <n>", `fields per page, 1–${MOST}`, integer(MOST), 50)
447
+ .option("--page <n>", "which page, starting at 1", integer(1_000_000), 1)
448
+ .option("--all", "every field, no paging"), FIELDS_QUERY), "fields list deals", "fields list people --json").action(async (kind, words, opts, command) => {
449
+ if (!fieldKinds.includes(kind))
450
+ throw new CliError("validation_error", `fields are listed for ${fieldKinds.join(", ")}; got ${kind}`, {
451
+ reason: "field.unknown-kind",
452
+ allowed: fieldKinds,
453
+ });
454
+ queryTerms(words, FIELDS_QUERY, "fields list");
455
+ await within(command, async (run) => {
456
+ const found = await run.store.crm.fields(run.account, FIELD_KINDS[kind]);
457
+ const offset = opts.all ? 0 : (opts.page - 1) * opts.limit;
458
+ const end = opts.all ? found.length : offset + opts.limit;
459
+ return listOf(pathOf(command).join(" "), {
460
+ items: found.slice(offset, end).map((field) => ({ ...field, untrusted: FIELD_UNTRUSTED })),
461
+ limit: opts.all ? Math.max(1, found.length) : opts.limit,
462
+ page: opts.page,
463
+ has_more: found.length > end,
464
+ ...(words.length ? { args: { query: words } } : {}),
465
+ });
466
+ });
467
+ });
411
468
  const search = program.commands.find((one) => one.name() === "search") ??
412
469
  annotate(program.command("search"), { effects: NO_EFFECTS }).description("search what the local store holds");
413
470
  addCrmSearchAll(search, { within, app: options.app, example });
@@ -1,4 +1,4 @@
1
1
  export type { CrmChanges, CrmIdPage, CrmPage, CrmSource, CrmSourceTraits } from "./source.js";
2
2
  export { type CrmCursor, type CrmSyncIssue, type CrmSyncOptions, type CrmSyncReport, type CrmSyncResult, crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
3
- export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, } from "./writer.js";
3
+ export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, crmAlsoChecks, } from "./writer.js";
4
4
  export { type CrmFieldsInput, type CrmWriteContext, type CrmWrites, type CrmWritten, closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";