@quillmeetings/cli 0.1.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.
package/src/config.js ADDED
@@ -0,0 +1,132 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export function defaultConfig() {
6
+ return {
7
+ mcp: {
8
+ command: "node",
9
+ args: [defaultBridgePath()],
10
+ framing: "newline",
11
+ timeout_ms: 15000,
12
+ mutation_timeout_ms: 120000,
13
+ max_buffer_bytes: 10 * 1024 * 1024,
14
+ },
15
+ output: {
16
+ format: "human",
17
+ limit: 20,
18
+ truncate: 1200,
19
+ },
20
+ browse: {
21
+ limit: 20,
22
+ panel_truncate: 5000,
23
+ },
24
+ agent: {
25
+ enabled: false,
26
+ },
27
+ };
28
+ }
29
+
30
+ export function defaultBridgePath() {
31
+ const home = os.homedir();
32
+ if (process.platform === "darwin") {
33
+ return path.join(home, "Library", "Application Support", "Quill", "mcp-stdio-bridge.js");
34
+ }
35
+ if (process.platform === "win32") {
36
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
37
+ return path.join(appData, "Quill", "mcp-stdio-bridge.js");
38
+ }
39
+ const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
40
+ return path.join(xdgDataHome, "Quill", "mcp-stdio-bridge.js");
41
+ }
42
+
43
+ export function defaultQuillDataDir() {
44
+ const home = os.homedir();
45
+ if (process.platform === "darwin") return path.join(home, "Library", "Application Support", "Quill");
46
+ if (process.platform === "win32") {
47
+ const appData = process.env.APPDATA || path.join(home, "AppData", "Roaming");
48
+ return path.join(appData, "Quill");
49
+ }
50
+ const xdgDataHome = process.env.XDG_DATA_HOME || path.join(home, ".local", "share");
51
+ return path.join(xdgDataHome, "Quill");
52
+ }
53
+
54
+ export function supportedPlatform() {
55
+ return process.platform === "darwin" || process.platform === "win32";
56
+ }
57
+
58
+ export function loadConfig() {
59
+ return mergeConfig(defaultConfig(), readUserConfig());
60
+ }
61
+
62
+ export function configPath() {
63
+ if (process.env.QUILL_CONFIG) return path.resolve(process.env.QUILL_CONFIG);
64
+ const base = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
65
+ return path.join(base, "quill-cli", "config.json");
66
+ }
67
+
68
+ export function ensureConfigFile() {
69
+ const target = configPath();
70
+ if (fs.existsSync(target)) return { path: target, created: false };
71
+ fs.mkdirSync(path.dirname(target), { recursive: true });
72
+ fs.writeFileSync(target, `${JSON.stringify(defaultConfig(), null, 2)}\n`);
73
+ return { path: target, created: true };
74
+ }
75
+
76
+ export function readUserConfig() {
77
+ const target = configPath();
78
+ if (!fs.existsSync(target)) return {};
79
+ try {
80
+ return JSON.parse(fs.readFileSync(target, "utf8"));
81
+ } catch (error) {
82
+ error.code = "config_parse_error";
83
+ error.message = `Failed to parse config ${target}: ${error.message}`;
84
+ throw error;
85
+ }
86
+ }
87
+
88
+ export function writeUserConfig(config) {
89
+ const target = configPath();
90
+ fs.mkdirSync(path.dirname(target), { recursive: true });
91
+ fs.writeFileSync(target, `${JSON.stringify(config, null, 2)}\n`);
92
+ }
93
+
94
+ export function getConfigValue(config, key) {
95
+ return key.split(".").reduce((value, part) => value?.[part], config);
96
+ }
97
+
98
+ export function setConfigValue(config, key, rawValue) {
99
+ const parts = key.split(".");
100
+ let cursor = config;
101
+ for (const part of parts.slice(0, -1)) {
102
+ if (!cursor[part] || typeof cursor[part] !== "object" || Array.isArray(cursor[part])) cursor[part] = {};
103
+ cursor = cursor[part];
104
+ }
105
+ cursor[parts.at(-1)] = parseConfigValue(rawValue);
106
+ return config;
107
+ }
108
+
109
+ function parseConfigValue(value) {
110
+ if (value === "true") return true;
111
+ if (value === "false") return false;
112
+ if (value === "null") return null;
113
+ if (/^-?\d+$/.test(value)) return Number.parseInt(value, 10);
114
+ if (/^-?\d+\.\d+$/.test(value)) return Number.parseFloat(value);
115
+ if ((value.startsWith("[") && value.endsWith("]")) || (value.startsWith("{") && value.endsWith("}"))) {
116
+ return JSON.parse(value);
117
+ }
118
+ return value;
119
+ }
120
+
121
+ function mergeConfig(base, override) {
122
+ const result = { ...base };
123
+ for (const [key, value] of Object.entries(override || {})) {
124
+ if (isPlainObject(value) && isPlainObject(base[key])) result[key] = mergeConfig(base[key], value);
125
+ else result[key] = value;
126
+ }
127
+ return result;
128
+ }
129
+
130
+ function isPlainObject(value) {
131
+ return value !== null && typeof value === "object" && !Array.isArray(value);
132
+ }
package/src/doctor.js ADDED
@@ -0,0 +1,190 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { defaultBridgePath, defaultQuillDataDir, supportedPlatform } from "./config.js";
5
+ import { McpClient } from "./mcp-client.js";
6
+
7
+ export const CLI_VERSION = "0.1.0";
8
+
9
+ const DOWNLOAD_URL = "https://www.quillmeetings.com/download";
10
+ const MCP_SETTINGS_HINT = "Open Quill Settings -> MCP / Integrations and enable the MCP server.";
11
+
12
+ export async function runDoctorChecks(config, options = {}) {
13
+ const checks = [];
14
+ const platform = platformName();
15
+ const supported = supportedPlatform();
16
+ const quillDir = defaultQuillDataDir();
17
+ const defaultBridge = defaultBridgePath();
18
+ const configuredArgs = Array.isArray(config.mcp?.args) ? config.mcp.args : [];
19
+ const configuredBridge = configuredArgs[0];
20
+ const quillDirExists = fs.existsSync(quillDir);
21
+ const defaultBridgeExists = fs.existsSync(defaultBridge);
22
+ const configuredBridgeExists = configuredBridge ? fs.existsSync(configuredBridge) : false;
23
+
24
+ checks.push({
25
+ name: "Platform support",
26
+ status: supported ? "PASS" : "FAIL",
27
+ message: supported ? `${platform} is supported.` : `${platform} is not supported yet.`,
28
+ remediation: supported ? undefined : "Use Quill CLI on macOS or Windows.",
29
+ });
30
+
31
+ checks.push({
32
+ name: "Quill desktop data",
33
+ status: quillDirExists ? "PASS" : "FAIL",
34
+ message: quillDirExists ? `Found ${quillDir}.` : `Could not find ${quillDir}.`,
35
+ remediation: quillDirExists ? undefined : `Install and launch Quill desktop: ${DOWNLOAD_URL}`,
36
+ });
37
+
38
+ checks.push({
39
+ name: "Default MCP bridge",
40
+ status: defaultBridgeExists ? "PASS" : quillDirExists ? "FAIL" : "WARN",
41
+ message: defaultBridgeExists ? `Found ${defaultBridge}.` : `Could not find ${defaultBridge}.`,
42
+ remediation: defaultBridgeExists ? undefined : quillDirExists ? "Update Quill desktop, then run `quill doctor` again." : "Install and launch Quill desktop first.",
43
+ });
44
+
45
+ checks.push(configBridgeCheck(configuredBridge, configuredBridgeExists, defaultBridge, defaultBridgeExists));
46
+
47
+ const appVersion = findQuillAppVersion();
48
+ checks.push({
49
+ name: "Version info",
50
+ status: appVersion ? "PASS" : "WARN",
51
+ message: appVersion ? `CLI ${CLI_VERSION}, Quill app ${appVersion}.` : `CLI ${CLI_VERSION}, Quill app version not discovered.`,
52
+ remediation: appVersion ? undefined : "If MCP calls fail, update Quill desktop and rerun `quill doctor`.",
53
+ });
54
+
55
+ if (supported && configuredBridgeExists) {
56
+ checks.push(await handshakeCheck(config, options.timeoutMs));
57
+ } else {
58
+ checks.push({
59
+ name: "MCP handshake",
60
+ status: "WARN",
61
+ message: "Skipped because the configured bridge is not available.",
62
+ remediation: "Fix the bridge path above, then run `quill doctor` again.",
63
+ });
64
+ }
65
+
66
+ const failing = checks.filter((check) => check.status === "FAIL");
67
+ const warnings = checks.filter((check) => check.status === "WARN");
68
+ const firstIssue = failing[0] || warnings[0];
69
+ return {
70
+ title: "Quill doctor",
71
+ platform: process.platform,
72
+ quill_dir: quillDir,
73
+ default_bridge: defaultBridge,
74
+ configured_bridge: configuredBridge,
75
+ versions: {
76
+ cli: CLI_VERSION,
77
+ quill_app: appVersion || null,
78
+ },
79
+ checks,
80
+ issue_count: failing.length + warnings.length,
81
+ all_good: failing.length === 0 && warnings.length === 0,
82
+ start_with: firstIssue?.remediation || "Run `quill browse`.",
83
+ };
84
+ }
85
+
86
+ function configBridgeCheck(configuredBridge, configuredBridgeExists, defaultBridge, defaultBridgeExists) {
87
+ if (!configuredBridge) {
88
+ return {
89
+ name: "CLI bridge config",
90
+ status: "FAIL",
91
+ message: "Config has no mcp.args bridge path.",
92
+ remediation: resetBridgeCommand(defaultBridge),
93
+ };
94
+ }
95
+
96
+ if (configuredBridge === defaultBridge) {
97
+ return {
98
+ name: "CLI bridge config",
99
+ status: configuredBridgeExists ? "PASS" : "FAIL",
100
+ message: configuredBridgeExists ? "Config points at the platform default bridge." : "Config points at the platform default bridge, but the file is missing.",
101
+ remediation: configuredBridgeExists ? undefined : "Install or update Quill desktop.",
102
+ };
103
+ }
104
+
105
+ if (configuredBridgeExists) {
106
+ return {
107
+ name: "CLI bridge config",
108
+ status: "WARN",
109
+ message: `Config points at a custom bridge: ${configuredBridge}.`,
110
+ remediation: defaultBridgeExists ? resetBridgeCommand(defaultBridge) : "Keep this only if you intentionally use a custom Quill bridge.",
111
+ };
112
+ }
113
+
114
+ return {
115
+ name: "CLI bridge config",
116
+ status: "FAIL",
117
+ message: `Configured bridge is missing: ${configuredBridge}.`,
118
+ remediation: defaultBridgeExists ? resetBridgeCommand(defaultBridge) : "Install or update Quill desktop, or set mcp.args to the correct bridge path.",
119
+ };
120
+ }
121
+
122
+ async function handshakeCheck(config, timeoutMs = 3000) {
123
+ const client = new McpClient({
124
+ ...config.mcp,
125
+ timeout_ms: timeoutMs,
126
+ });
127
+
128
+ try {
129
+ await client.connect();
130
+ const tools = await client.listTools();
131
+ if (tools.length === 0) {
132
+ return {
133
+ name: "MCP handshake",
134
+ status: "WARN",
135
+ message: "Connected to the bridge, but no MCP tools were listed.",
136
+ remediation: MCP_SETTINGS_HINT,
137
+ };
138
+ }
139
+ return {
140
+ name: "MCP handshake",
141
+ status: "PASS",
142
+ message: `Connected and found ${tools.length} MCP tools.`,
143
+ };
144
+ } catch (error) {
145
+ return {
146
+ name: "MCP handshake",
147
+ status: "FAIL",
148
+ message: error?.code === "mcp_timeout" ? "Timed out waiting for the Quill MCP bridge." : error?.message || String(error),
149
+ remediation: error?.code === "mcp_timeout" ? MCP_SETTINGS_HINT : "Run `quill doctor --json` for details, then update Quill or reset the bridge path.",
150
+ };
151
+ } finally {
152
+ await client.close();
153
+ }
154
+ }
155
+
156
+ function findQuillAppVersion() {
157
+ if (process.platform === "darwin") {
158
+ const candidates = [
159
+ "/Applications/Quill.app/Contents/Info.plist",
160
+ path.join(os.homedir(), "Applications", "Quill.app", "Contents", "Info.plist"),
161
+ ];
162
+ for (const candidate of candidates) {
163
+ const version = readMacPlistVersion(candidate);
164
+ if (version) return version;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function readMacPlistVersion(file) {
171
+ if (!fs.existsSync(file)) return null;
172
+ const text = fs.readFileSync(file, "utf8");
173
+ return plistStringAfterKey(text, "CFBundleShortVersionString") || plistStringAfterKey(text, "CFBundleVersion");
174
+ }
175
+
176
+ function plistStringAfterKey(text, key) {
177
+ const match = text.match(new RegExp(`<key>${key}<\\/key>\\s*<string>([^<]+)<\\/string>`));
178
+ return match?.[1] || null;
179
+ }
180
+
181
+ function resetBridgeCommand(defaultBridge) {
182
+ return `Run \`quill config set mcp.args '${JSON.stringify([defaultBridge])}'\`.`;
183
+ }
184
+
185
+ function platformName() {
186
+ if (process.platform === "darwin") return "macOS";
187
+ if (process.platform === "win32") return "Windows";
188
+ if (process.platform === "linux") return "Linux";
189
+ return process.platform;
190
+ }
package/src/format.js ADDED
@@ -0,0 +1,275 @@
1
+ export function printData(data, options = {}) {
2
+ data = shapeForOutput(data, options);
3
+ const format = options.format || "human";
4
+ if (format === "json") {
5
+ process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
6
+ return;
7
+ }
8
+
9
+ if (format === "human" || options.human) {
10
+ const rendered = toHuman(data);
11
+ if (rendered) {
12
+ process.stdout.write(`${rendered}\n`);
13
+ return;
14
+ }
15
+ }
16
+
17
+ process.stdout.write(`${toToon(data)}\n`);
18
+ }
19
+
20
+ export function shapeForOutput(data, options = {}) {
21
+ const cloned = clone(data);
22
+ applyFieldSelection(cloned, options);
23
+ if (!options.full) truncateLargeStrings(cloned, Number.parseInt(options.truncate || "1200", 10));
24
+ return cloned;
25
+ }
26
+
27
+ export function toHuman(data) {
28
+ const result = data?.result || data;
29
+ if (!result || typeof result !== "object") return null;
30
+
31
+ if (result.bin && result.description) return toStatusScreen(result, data.help);
32
+ if (result.config_path && result.mcp_bridge) return toStatusScreen({ title: "Quill setup", ...result }, data.help);
33
+ if (result.title === "Quill doctor" && Array.isArray(result.checks)) return toDoctorScreen(result);
34
+
35
+ for (const key of ["meetings", "events", "contacts", "templates", "threads", "notes"]) {
36
+ if (Array.isArray(result[key])) {
37
+ const rows = result[key].map((item) => humanizeRow(item));
38
+ const title = `${capitalize(key)} (${result.count ?? rows.length})`;
39
+ const help = Array.isArray(data.help) ? `\n\n${data.help.join("\n")}` : "";
40
+ return `${title}\n${toTable(rows)}${help}`;
41
+ }
42
+ }
43
+
44
+ if (typeof result.message === "string") return result.message;
45
+ return null;
46
+ }
47
+
48
+ function toDoctorScreen(result) {
49
+ const lines = [result.title, ""];
50
+ for (const check of result.checks) {
51
+ lines.push(`${check.status.padEnd(4)} ${check.name}: ${check.message}`);
52
+ if (check.remediation) lines.push(` ${check.remediation}`);
53
+ }
54
+ lines.push("");
55
+ if (result.all_good) {
56
+ lines.push("All good. Run `quill browse`.");
57
+ } else {
58
+ lines.push(`${result.issue_count} issue${result.issue_count === 1 ? "" : "s"} - start with: ${result.start_with}`);
59
+ }
60
+ return lines.join("\n");
61
+ }
62
+
63
+ function toStatusScreen(data, help) {
64
+ const lines = [
65
+ `${data.bin || data.title}`,
66
+ ];
67
+ if (data.description) lines.push(data.description);
68
+ const rows = Object.entries(data)
69
+ .filter(([key]) => !["bin", "title", "description", "next", "help"].includes(key))
70
+ .map(([key, value]) => [humanLabel(key), formatHumanValue(value)]);
71
+
72
+ if (rows.length > 0) {
73
+ lines.push("");
74
+ const width = Math.max(...rows.map(([key]) => key.length));
75
+ lines.push(...rows.map(([key, value]) => `${key.padEnd(width)} ${value}`));
76
+ }
77
+
78
+ if (data.next) lines.push("", data.next);
79
+ else if (Array.isArray(help) && help.length > 0) lines.push("", ...help);
80
+ return lines.join("\n");
81
+ }
82
+
83
+ export function toToon(value, indent = 0, key = null) {
84
+ const pad = " ".repeat(indent);
85
+
86
+ if (Array.isArray(value)) {
87
+ if (value.length === 0) return key ? `${pad}${key}[0]:` : `${pad}items[0]:`;
88
+ if (value.every(isFlatObject)) {
89
+ const fields = collectFields(value);
90
+ const header = key ? `${key}[${value.length}]{${fields.join(",")}}:` : `items[${value.length}]{${fields.join(",")}}:`;
91
+ const rows = value.map((item) => `${pad} ${fields.map((field) => scalar(item[field])).join(",")}`);
92
+ return [`${pad}${header}`, ...rows].join("\n");
93
+ }
94
+ const header = key ? `${pad}${key}[${value.length}]:` : `${pad}items[${value.length}]:`;
95
+ return [header, ...value.map((item) => toToon(item, indent + 2))].join("\n");
96
+ }
97
+
98
+ if (isObject(value)) {
99
+ const lines = [];
100
+ if (key) lines.push(`${pad}${key}:`);
101
+ for (const [childKey, childValue] of Object.entries(value)) {
102
+ if (Array.isArray(childValue) || isObject(childValue)) {
103
+ lines.push(toToon(childValue, key ? indent + 2 : indent, childKey));
104
+ } else {
105
+ lines.push(`${key ? " ".repeat(indent + 2) : pad}${childKey}: ${scalar(childValue)}`);
106
+ }
107
+ }
108
+ return lines.join("\n");
109
+ }
110
+
111
+ return key ? `${pad}${key}: ${scalar(value)}` : `${pad}${scalar(value)}`;
112
+ }
113
+
114
+ export function structuredError(code, message, details = undefined) {
115
+ const error = { code, message };
116
+ if (details !== undefined) error.details = details;
117
+ return { error };
118
+ }
119
+
120
+ export function withHelp(data, commands) {
121
+ return {
122
+ ...data,
123
+ help: commands,
124
+ };
125
+ }
126
+
127
+ export function truncateText(value, limit = 1200) {
128
+ if (typeof value !== "string" || value.length <= limit) return value;
129
+ return `${value.slice(0, limit)}... (truncated, ${value.length} chars total; use --full to see complete text)`;
130
+ }
131
+
132
+ function collectFields(items) {
133
+ const preferred = ["id", "title", "name", "date", "start", "duration", "status"];
134
+ const keys = [...new Set(items.flatMap((item) => Object.keys(item)))];
135
+ const sorted = [...preferred.filter((key) => keys.includes(key)), ...keys.filter((key) => !preferred.includes(key))];
136
+ return sorted.slice(0, 4);
137
+ }
138
+
139
+ function isFlatObject(value) {
140
+ return isObject(value) && Object.values(value).every((child) => !Array.isArray(child) && !isObject(child));
141
+ }
142
+
143
+ function isObject(value) {
144
+ return value !== null && typeof value === "object" && !Array.isArray(value);
145
+ }
146
+
147
+ function scalar(value) {
148
+ if (value === null || value === undefined) return "";
149
+ if (typeof value === "string") {
150
+ const normalized = value.replace(/\r?\n/g, "\\n");
151
+ return /[,"\n]/.test(normalized) ? JSON.stringify(normalized) : normalized;
152
+ }
153
+ if (typeof value === "boolean") return value ? "true" : "false";
154
+ return String(value);
155
+ }
156
+
157
+ function humanizeRow(item) {
158
+ const row = { ...item };
159
+ for (const key of ["date", "start", "end", "started_at", "created_at"]) {
160
+ if (row[key]) row[key] = relativeTime(row[key]);
161
+ }
162
+ return row;
163
+ }
164
+
165
+ function relativeTime(value) {
166
+ const time = new Date(value).getTime();
167
+ if (Number.isNaN(time)) return value;
168
+ const seconds = Math.round((time - Date.now()) / 1000);
169
+ const abs = Math.abs(seconds);
170
+ const units = [
171
+ ["year", 31536000],
172
+ ["month", 2592000],
173
+ ["week", 604800],
174
+ ["day", 86400],
175
+ ["hour", 3600],
176
+ ["minute", 60],
177
+ ];
178
+ for (const [name, size] of units) {
179
+ if (abs >= size) {
180
+ const amount = Math.round(abs / size);
181
+ return seconds < 0 ? `${amount} ${name}${amount === 1 ? "" : "s"} ago` : `in ${amount} ${name}${amount === 1 ? "" : "s"}`;
182
+ }
183
+ }
184
+ return seconds < 0 ? "just now" : "now";
185
+ }
186
+
187
+ function toTable(rows) {
188
+ if (rows.length === 0) return "(none)";
189
+ const fields = collectFields(rows);
190
+ const widths = fields.map((field) => Math.max(field.length, ...rows.map((row) => truncateCell(row[field]).length)));
191
+ const header = fields.map((field, index) => field.padEnd(widths[index])).join(" ");
192
+ const divider = widths.map((width) => "-".repeat(width)).join(" ");
193
+ const body = rows.map((row) => fields.map((field, index) => truncateCell(row[field]).padEnd(widths[index])).join(" "));
194
+ return [header, divider, ...body].join("\n");
195
+ }
196
+
197
+ function truncateCell(value) {
198
+ const text = value === null || value === undefined ? "" : String(value).replace(/\s+/g, " ");
199
+ return text.length > 48 ? `${text.slice(0, 45)}...` : text;
200
+ }
201
+
202
+ function formatHumanValue(value) {
203
+ if (value === true) return "yes";
204
+ if (value === false) return "no";
205
+ if (Array.isArray(value)) return value.join(", ");
206
+ if (isObject(value)) return JSON.stringify(value);
207
+ return value === null || value === undefined ? "" : String(value);
208
+ }
209
+
210
+ function humanLabel(value) {
211
+ return String(value)
212
+ .replace(/_/g, " ")
213
+ .replace(/\b\w/g, (char) => char.toUpperCase())
214
+ .replace(/\bMcp\b/g, "MCP");
215
+ }
216
+
217
+ function capitalize(value) {
218
+ return value.charAt(0).toUpperCase() + value.slice(1);
219
+ }
220
+
221
+ function clone(value) {
222
+ if (value === undefined) return value;
223
+ return JSON.parse(JSON.stringify(value));
224
+ }
225
+
226
+ function applyFieldSelection(data, options) {
227
+ const fields = options.fields;
228
+ const collections = findCollections(data);
229
+ for (const collection of collections) {
230
+ const selected = fields || defaultFieldsFor(collection.key);
231
+ if (!selected) continue;
232
+ collection.items.forEach((item) => {
233
+ for (const key of Object.keys(item)) {
234
+ if (!selected.includes(key)) delete item[key];
235
+ }
236
+ });
237
+ }
238
+ }
239
+
240
+ function findCollections(value, collections = []) {
241
+ if (!isObject(value)) return collections;
242
+ for (const [key, child] of Object.entries(value)) {
243
+ if (Array.isArray(child) && child.every(isFlatObject)) {
244
+ collections.push({ key, items: child });
245
+ } else if (isObject(child)) {
246
+ findCollections(child, collections);
247
+ }
248
+ }
249
+ return collections;
250
+ }
251
+
252
+ function defaultFieldsFor(key) {
253
+ const fields = {
254
+ meetings: ["id", "title", "date", "duration"],
255
+ events: ["id", "title", "start", "end"],
256
+ contacts: ["id", "name", "email"],
257
+ templates: ["id", "name", "kind", "verb"],
258
+ threads: ["id", "title", "updated_at"],
259
+ notes: ["id", "title", "template_id", "created_at"],
260
+ tools: ["name", "description"],
261
+ };
262
+ return fields[key];
263
+ }
264
+
265
+ function truncateLargeStrings(value, limit) {
266
+ if (Array.isArray(value)) {
267
+ value.forEach((item) => truncateLargeStrings(item, limit));
268
+ return;
269
+ }
270
+ if (!isObject(value)) return;
271
+ for (const [key, child] of Object.entries(value)) {
272
+ if (typeof child === "string") value[key] = truncateText(child, limit);
273
+ else truncateLargeStrings(child, limit);
274
+ }
275
+ }