@wirecat/cli-core 0.596.0 → 0.598.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 (62) hide show
  1. package/CHANGELOG.md +79 -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/cli/index.js +4 -11
  7. package/dist/calendar/mcp/index.d.ts +12 -12
  8. package/dist/cli/context.d.ts +12 -0
  9. package/dist/cli/context.js +17 -3
  10. package/dist/cli/contract-templates.js +1 -0
  11. package/dist/cli/early-refusal.js +7 -5
  12. package/dist/cli/index.d.ts +2 -2
  13. package/dist/cli/index.js +1 -1
  14. package/dist/cli/permission-keys.d.ts +10 -2
  15. package/dist/cli/preview.d.ts +3 -0
  16. package/dist/cli/preview.js +9 -5
  17. package/dist/cli/program.js +3 -0
  18. package/dist/crm/cli/index.js +76 -17
  19. package/dist/crm/index.d.ts +1 -1
  20. package/dist/crm/index.js +1 -1
  21. package/dist/crm/source.d.ts +6 -1
  22. package/dist/crm/sync.d.ts +5 -4
  23. package/dist/crm/sync.js +22 -4
  24. package/dist/crm/testing/index.d.ts +1 -1
  25. package/dist/crm/testing/index.js +1 -1
  26. package/dist/crm/testing/source-contract.js +28 -0
  27. package/dist/crm/testing/source.d.ts +8 -1
  28. package/dist/crm/testing/source.js +53 -9
  29. package/dist/crm/testing/sync-contract.js +14 -3
  30. package/dist/crm/writer.d.ts +11 -2
  31. package/dist/crm/writer.js +31 -0
  32. package/dist/crm/writes.d.ts +12 -2
  33. package/dist/crm/writes.js +93 -8
  34. package/dist/domain/crm.d.ts +29 -1
  35. package/dist/entities/deal.d.ts +16 -0
  36. package/dist/entities/deal.js +8 -0
  37. package/dist/entities/index.d.ts +12 -0
  38. package/dist/errors/reasons.js +4 -0
  39. package/dist/index.d.ts +1 -1
  40. package/dist/kit/index.d.ts +1 -0
  41. package/dist/kit/index.js +1 -0
  42. package/dist/kit/run-scope.d.ts +34 -0
  43. package/dist/kit/run-scope.js +112 -0
  44. package/dist/mcp/app-server.js +7 -2
  45. package/dist/meetings/cli/index.d.ts +5 -0
  46. package/dist/meetings/cli/index.js +4 -3
  47. package/dist/meetings/mcp/index.d.ts +15 -15
  48. package/dist/store/driver.d.ts +2 -0
  49. package/dist/store/driver.js +2 -0
  50. package/dist/store/search-index.js +6 -1
  51. package/dist/store/sqlite/accounts.js +1 -0
  52. package/dist/store/sqlite/crm.d.ts +5 -1
  53. package/dist/store/sqlite/crm.js +52 -0
  54. package/dist/store/sqlite/deals.d.ts +5 -0
  55. package/dist/store/sqlite/deals.js +13 -0
  56. package/dist/store/sqlite/manifest.js +1 -0
  57. package/dist/store/sqlite/migrations.generated.js +6 -0
  58. package/dist/store/sqlite/note-index.d.ts +1 -1
  59. package/dist/store/sqlite/note-index.js +3 -0
  60. package/dist/store/sqlite/schema.d.ts +217 -0
  61. package/dist/store/sqlite/schema.js +18 -0
  62. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,6 +5,85 @@ 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.598.0 — 07.11.2026
11
+
12
+ ### Added
13
+
14
+ - **`runScopeCases`** (`@wirecat/cli-core/testing`): cases that each remote command of a program runs inside
15
+ cli-core's run, so `--record` keeps a run named after it, and that `--timeout` ends it when the service never
16
+ answers. A command beside the run answers under the process run and fails. `neverAnswers` is the `fetch` for the
17
+ silent run.
18
+ - **`inCommandRun(command, body)`** (`@wirecat/cli-core/cli`): runs `body` inside cli-core's run with the settings of
19
+ the app `run()` was given, which `run()` now hands every command (`environment.resolveSettings`).
20
+
21
+ ### Changed — may break callers
22
+
23
+ - **`addMeetingCommands` takes `inRun`**, which `meetings sync` runs inside: pass `inCommandRun` from
24
+ `@wirecat/cli-core/cli`. The meetings module cannot reach cli-core's run itself.
25
+
26
+ ### Fixed
27
+
28
+ - **The shared CRM, calendar and meetings commands run inside cli-core's run.** `sync` and the deal and lead writes
29
+ from `addCrmCommands`, every command from `addCalendarCommands`, and `meetings sync` answered under the process
30
+ run: `--record` kept no run, and `runs show` could not find the id they answered. A CLI that wrapped them in a run
31
+ of its own can drop that.
32
+ - **An MCP server's `--permission` levels refuse a messenger write before it connects.** The early refusal of a change
33
+ on the messenger read the command's flags only, so `mcp serve --permission messages.send=deny` let the command
34
+ connect and left the refusal to the send guard. It now merges the server's levels as `baseContext` does.
35
+ `withAgentLevels(command, flags)` (`@wirecat/cli-core/cli`) is that merge, for a CLI's own resolver.
36
+ - **An app's `mcp serve` over stdio ends when it was stopped before it listened.** `serveAppOverStdio` waited only
37
+ for an abort event, which a signal that had already fired never sends, so the server hung with stdin open.
38
+
39
+ ## 0.597.0 — 07.11.2026
40
+
41
+ ### Added
42
+
43
+ - **`--field` by label.** CRM `create` and `update` resolve `--field <key>=<value>` by the synced field definitions:
44
+ a field by its code or whole label (a label two fields share is refused as `field.ambiguous` with both codes, an
45
+ unknown one as `field.unknown` with close candidates), a list field's option by its id or label
46
+ (`field.unknown-option`), and a multiple field's repeats into a list; a single field given twice is refused. A CRM
47
+ whose definitions are not stored takes codes as typed.
48
+ - **`CrmWrites.alsoChecks(kind, verb)`**: a CLI's own keys a write also checks, such as `api.crm.deal.update`.
49
+ `crmAlsoChecks(also)` gives `commandPermissions({ alsoChecks })` every key a CRM write may check, so `--dry-run`
50
+ names the implied move, close and `crm.assign-others` keys and the CLI's own. A key an argument implies is a
51
+ `ConditionalKey` `{ key, when }`: `commandPermissions({ alsoChecks })` may return one, and the preview shows its level
52
+ and `when` but never refuses on it.
53
+ - **A deal's and lead's `provider_metadata` and `assignee`.** The CRM's own field values, by code as
54
+ `{ label, value, option_id? }` (`CrmValue`), kept as stored in `metadata`, codes never renamed; `CrmItem.assignee`, the
55
+ CRM user it is assigned to, answered as the identity `<account>/user/<id>`.
56
+ - **CRM field definitions (store version 54).** `CrmSource.fields?(kind)` lists a CRM's own fields on deals, leads,
57
+ contacts or companies: `CrmField` `{ code, label, type, multiple, filterable, options? }`. `syncCrm` reads them every
58
+ run, after the pipelines, and `store.crm.saveFields` keeps each kind's whole list in `crm_fields`: one the CRM no
59
+ longer lists is marked deleted, and an empty list marks them all. `store.crm.fields(account, kind)` reads them in
60
+ the CRM's order. Values stay in `metadata`, keyed by code. The migration adds one table; `minCompatible` stays 34.
61
+ - **`fields list <deals|leads|people|organizations>`** in `addCrmCommands`: the synced fields of one kind, with
62
+ `--limit`, `--page` and `--all`, in bitrix24-cli's `fields list` shape plus `untrusted`. An unknown kind is refused
63
+ as `field.unknown-kind` with the allowed words.
64
+ - **`crmSourceContract` checks `fields()`** when an adapter has it, and `crmSyncContract` checks they are stored. The
65
+ fake CRM has `setFields`, `calls.fields` and `SOURCE_FIELD` on its deals.
66
+ - **A schedule's Telegram delivery is Telegram HTML.** `--to telegram-bot:<profile>/<chat>` converts the report's
67
+ Markdown (headings, bold, italic, code, links, lists, tables as label lines, rules) to the tags `tg` accepts and
68
+ sends it with `--html`; the 4096 and 1024 character limits count the converted text, and a long report's caption
69
+ is cut until its HTML fits. When `tg` answers `validation_error`, the send is repeated once as plain text and
70
+ recorded as `delivery.html-fallback`; any other failure is never resent. Email stays plain text.
71
+
72
+ ### Changed — may break callers
73
+
74
+ - **`CrmFieldsInput.custom` is the `--field` pairs as typed** (`[name, value][]`), and `CrmFields.custom` values may be
75
+ a list for a multiple field.
76
+ - **A write the CRM made whose item could not be read back is journaled as sent.** A `CrmWriter` throws it with
77
+ `sent: true` in the details; the error is answered with the write's `operation_id`, no longer journaled as failed.
78
+ - **A CRM sync with `fields()` makes one more request per kind**, so a `--max-pages` budget reaches the items later.
79
+
80
+ ### Fixed
81
+
82
+ - **A search over a read-only store no longer fails with "attempt to write a readonly database".** The message index
83
+ top-up and the notes, mail, events, memories and tasks index drains are skipped on a connection with `query_only`
84
+ (every read-only open), so `search all` in a CRM CLI answers from every source of a store only a CRM sync wrote. They
85
+ run on the next writable open.
86
+
8
87
  ## 0.596.0 — 06.11.2026
9
88
 
10
89
  ### 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
+ };
@@ -3,7 +3,7 @@ import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { timezoneOf } from "@wirecat/cli-base/query";
4
4
  import { clockMeta } from "@wirecat/cli-base/standards";
5
5
  import { Argument, InvalidArgumentError, Option } from "commander";
6
- import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
6
+ import { environmentOf, inCommandRun, outputFor, runBudgetOf } from "../../cli/context.js";
7
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
8
  import { changesStore, ownerOnlyOption, writesFileOnlyWith } from "../../cli/effects.js";
9
9
  import { renderAnswer } from "../../cli/envelope.js";
@@ -77,17 +77,10 @@ const addCalendarCommandsDefinition = (program, options) => {
77
77
  renderAnswer(command, output, body, view ?? ((shown) => localTimesView(shown, zone(), output.color)));
78
78
  };
79
79
  /** Opens the profile for `work` and closes it before the answer prints, also when `work` fails. */
80
- const within = async (command, work, view) => {
80
+ const within = (command, work, view) => inCommandRun(command, async () => {
81
81
  const run = await options.open(command, { readOnly: readsOnly(command) });
82
- let body;
83
- try {
84
- body = await work(run);
85
- }
86
- finally {
87
- await run.close?.();
88
- }
89
- answer(command, body, view);
90
- };
82
+ answer(command, await work(run).finally(() => run.close?.()), view);
83
+ });
91
84
  const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} personal ${line}`) });
92
85
  const calendars = annotate(program.command("calendars").description("the account's calendars"), { effects: GROUP });
93
86
  withQuery(example(annotate(calendars.command("list"), { effects: FETCH, offline: false }).description("list the account's calendars from the provider, and which of them sync"), "calendars list --json", "calendars list synced:true"), CALENDARS_QUERY).action(async (words, _opts, command) => {
@@ -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;
@@ -34,6 +34,8 @@ export interface BaseEnvironment {
34
34
  answer?: (question: string) => Promise<string | null> | string | null;
35
35
  /** Set by `run()`; a command's run is recorded only when it is known whose run it is. */
36
36
  app?: AppIdentity;
37
+ /** Set by `run()`: the app's own, so a shared command group opens its run with the app's settings. */
38
+ resolveSettings?: (flags: GlobalFlags, options?: ResolveOptions) => Settings;
37
39
  /** Set by `run()` from `process.platform` unless a test hands one in: the OS a command's paths and units are for. */
38
40
  platform?: NodeJS.Platform;
39
41
  /** The local speech recognizer in place of the downloaded model; tests hand one in. */
@@ -120,6 +122,10 @@ export declare const runBudgetOf: (command: Command, requests?: number) => RunBu
120
122
  export declare const setExitCode: (command: Command, code: number) => void;
121
123
  export declare const provide: (program: Command, environment: BaseEnvironment) => void;
122
124
  export declare const environmentOf: <E extends BaseEnvironment = BaseEnvironment>(command: Command) => E;
125
+ /** The command's flags with an MCP server's `--permission` levels first: they reach a run on its agent, never in argv. */
126
+ export declare const withAgentLevels: <F extends {
127
+ permission?: string[];
128
+ }>(command: Command, flags: F) => F;
123
129
  /**
124
130
  * The input and output `readSecret` uses for this command: the run's own, so a prompt goes where the run's other
125
131
  * output goes and a test that captures it sees it. `readSecret(prompt, { ...secretInputOf(this), echo: true })`.
@@ -166,4 +172,10 @@ export interface BaseContext {
166
172
  type Resolve = (flags: GlobalFlags, options?: ResolveOptions) => Settings;
167
173
  /** Everything a command needs that no messenger decides, resolved once. */
168
174
  export declare const baseContext: (command: Command, resolveSettings: Resolve) => BaseContext;
175
+ /**
176
+ * A shared group's command inside cli-core's run, with the settings of the app `run()` was given: recorded, and ended
177
+ * by `--timeout` (WC-LIFE-5, WC-LIFE-15). The group's own opener still decides whether it needs a profile. A program
178
+ * parsed without `run()` has no run to open, so `body` just runs.
179
+ */
180
+ export declare const inCommandRun: <T>(command: Command, body: (events: EventSink) => Promise<T>) => Promise<T>;
169
181
  export {};
@@ -37,6 +37,11 @@ export const provide = (program, environment) => {
37
37
  environments.set(program, environment);
38
38
  };
39
39
  export const environmentOf = (command) => (environments.get(rootOf(command)) ?? {});
40
+ /** The command's flags with an MCP server's `--permission` levels first: they reach a run on its agent, never in argv. */
41
+ export const withAgentLevels = (command, flags) => {
42
+ const overrides = environmentOf(command).agent?.overrides ?? [];
43
+ return overrides.length > 0 ? { ...flags, permission: [...overrides, ...(flags.permission ?? [])] } : flags;
44
+ };
40
45
  /**
41
46
  * The input and output `readSecret` uses for this command: the run's own, so a prompt goes where the run's other
42
47
  * output goes and a test that captures it sees it. `readSecret(prompt, { ...secretInputOf(this), echo: true })`.
@@ -70,9 +75,7 @@ export const outputFor = (command) => {
70
75
  export const baseContext = (command, resolveSettings) => {
71
76
  const environment = environmentOf(command);
72
77
  const env = environment.env ?? process.env;
73
- const flags = command.optsWithGlobals();
74
- const overrides = environment.agent?.overrides ?? [];
75
- const settings = resolveSettings(overrides.length > 0 ? { ...flags, permission: [...overrides, ...(flags.permission ?? [])] } : flags, { env });
78
+ const settings = resolveSettings(withAgentLevels(command, command.optsWithGlobals()), { env });
76
79
  const { renderer, format, color, streams, compact, full } = resolveOutput({
77
80
  ...settings,
78
81
  env,
@@ -141,6 +144,17 @@ export const baseContext = (command, resolveSettings) => {
141
144
  },
142
145
  };
143
146
  };
147
+ /**
148
+ * A shared group's command inside cli-core's run, with the settings of the app `run()` was given: recorded, and ended
149
+ * by `--timeout` (WC-LIFE-5, WC-LIFE-15). The group's own opener still decides whether it needs a profile. A program
150
+ * parsed without `run()` has no run to open, so `body` just runs.
151
+ */
152
+ export const inCommandRun = (command, body) => {
153
+ const { resolveSettings } = environmentOf(command);
154
+ if (!resolveSettings)
155
+ return body(() => { });
156
+ return baseContext(command, (flags, options) => resolveSettings(flags, { ...options, needsProfile: false })).run(body);
157
+ };
144
158
  /** `messages send` — the words that name commands, never the arguments: those can be a message. */
145
159
  const commandPath = (command) => {
146
160
  const words = [];
@@ -41,6 +41,7 @@ export const sharedCommandTemplate = (app, name) => {
41
41
  },
42
42
  accountId: 0,
43
43
  write: unavailable,
44
+ inRun: unavailable,
44
45
  });
45
46
  const meetings = root.commands.find((command) => command.name() === "meetings");
46
47
  if (!meetings)
@@ -7,7 +7,7 @@ import { SendJournal, sendsPathFor } from "../sends/journal.js";
7
7
  import { CEILING_KEY, keyForCommand, levelFor, writeOfKey } from "../sends/permissions.js";
8
8
  import { newOperationId } from "../sends/send-id.js";
9
9
  import { commandPathOf } from "./command-contract.js";
10
- import { environmentOf } from "./context.js";
10
+ import { withAgentLevels } from "./context.js";
11
11
  import { changesMessenger } from "./effects.js";
12
12
  import { refuseUnknownPermissionKeys } from "./permission-keys.js";
13
13
  import { NONE_NAMED, settingsFor } from "./settings.js";
@@ -33,7 +33,7 @@ export const refuseWriteBeforeConnecting = (action, { app, env, dryRun, resolve
33
33
  const key = keyForCommand(path);
34
34
  if (!key)
35
35
  return;
36
- const settings = (resolve ?? settingsFor(app).resolveSettings)(action.optsWithGlobals(), {
36
+ const settings = (resolve ?? settingsFor(app).resolveSettings)(withAgentLevels(action, action.optsWithGlobals()), {
37
37
  env,
38
38
  needsProfile: false,
39
39
  readOnly: true,
@@ -91,9 +91,11 @@ export const refuseByCommandPermissions = (action, permissions, { app, env, dryR
91
91
  const preview = dryRun && app.messenger === false;
92
92
  if (lock && effects?.remote === "write" && !preview)
93
93
  throw readOnlyRefusal(lock, `run ${commandPathOf(action).join(" ")}`);
94
- const flags = action.optsWithGlobals();
95
- const overrides = environmentOf(action).agent?.overrides ?? [];
96
- const settings = resolve(overrides.length > 0 ? { ...flags, permission: [...overrides, ...(flags.permission ?? [])] } : flags, { env, needsProfile: false, readOnly: true });
94
+ const settings = resolve(withAgentLevels(action, action.optsWithGlobals()), {
95
+ env,
96
+ needsProfile: false,
97
+ readOnly: true,
98
+ });
97
99
  refuseUnknownPermissionKeys(action, settings, app.command, permissions);
98
100
  if (dryRun || settings.sources?.profile === NONE_NAMED)
99
101
  return;
@@ -66,7 +66,7 @@ export { type CompactOptions, compactText, entityRows, GENERIC_ROW, type RowColu
66
66
  export { refuseUnknownKey } from "./config-command.js";
67
67
  export { type ConfigIssue, type ConfigIssueKind, checkConfigText, issueMessage, readConfigFile, readConfigText, settingPathOf, withoutSetting, } from "./config-issues.js";
68
68
  export { loadSnakeCaseConfig } from "./config-keys.js";
69
- export { type AgentRun, type BaseContext, type BaseEnvironment, baseContext, environmentOf, outputFor, provide, runBudgetOf, secretInputOf, } from "./context.js";
69
+ export { type AgentRun, type BaseContext, type BaseEnvironment, baseContext, environmentOf, inCommandRun, outputFor, provide, runBudgetOf, secretInputOf, withAgentLevels, } from "./context.js";
70
70
  export { addContractInfo, type ContractEvidenceOptions, contractEvidence } from "./contract-evidence.js";
71
71
  export { sharedCommandTemplate } from "./contract-templates.js";
72
72
  export { type Closeable, withDeadline } from "./deadline.js";
@@ -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";
package/dist/cli/index.js CHANGED
@@ -57,7 +57,7 @@ export { compactText, entityRows, GENERIC_ROW, rowShapeOf, rowsOfEntity, SHARED_
57
57
  export { refuseUnknownKey } from "./config-command.js";
58
58
  export { checkConfigText, issueMessage, readConfigFile, readConfigText, settingPathOf, withoutSetting, } from "./config-issues.js";
59
59
  export { loadSnakeCaseConfig } from "./config-keys.js";
60
- export { baseContext, environmentOf, outputFor, provide, runBudgetOf, secretInputOf, } from "./context.js";
60
+ export { baseContext, environmentOf, inCommandRun, outputFor, provide, runBudgetOf, secretInputOf, withAgentLevels, } from "./context.js";
61
61
  export { addContractInfo, contractEvidence } from "./contract-evidence.js";
62
62
  export { sharedCommandTemplate } from "./contract-templates.js";
63
63
  export { withDeadline } from "./deadline.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;