apiskill 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.
@@ -0,0 +1,520 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import {
3
+ createBlankDocument,
4
+ deleteManualOperation,
5
+ endpointToManualConfig,
6
+ findEndpoint,
7
+ formatManualCli,
8
+ listCachedVersions,
9
+ listEndpoints,
10
+ parseManualCliText,
11
+ readCachedDocument,
12
+ saveImportedDocument,
13
+ saveManualOperation,
14
+ } from './openapi-store.mjs';
15
+ import { crawlOpenApi, importFromCurl, importFromLocalFile, importFromUrl } from './openapi-importer.mjs';
16
+
17
+ const MAX_DEPTH = 12;
18
+
19
+ export const importExamples = {
20
+ web: [
21
+ 'apiskill run web',
22
+ 'Open the local URL, then import a direct OpenAPI JSON/YAML URL, crawl Swagger UI / Knife4j / Redoc, upload a file, or paste a curl command.',
23
+ ],
24
+ cli: [
25
+ 'npm run cli -- import https://example.com/openapi.json',
26
+ 'npm run cli -- crawl https://example.com/swagger',
27
+ 'npm run cli -- import-file ./openapi.yaml',
28
+ 'npm run cli -- import-curl --file ./request.curl',
29
+ ],
30
+ mcp: [
31
+ { tool: 'apiskill_import_url', arguments: { url: 'https://example.com/openapi.json' } },
32
+ { tool: 'apiskill_crawl_openapi', arguments: { url: 'https://example.com/swagger' } },
33
+ { tool: 'apiskill_import_file', arguments: { file: '/absolute/path/openapi.yaml' } },
34
+ { tool: 'apiskill_import_curl', arguments: { curlText: 'curl https://example.com/openapi.json' } },
35
+ ],
36
+ };
37
+
38
+ export async function checkCache() {
39
+ const versions = await listCachedVersions();
40
+ try {
41
+ const { document, meta } = await readCachedDocument();
42
+ const paths = Object.keys(document.paths ?? {}).length;
43
+ const schemas = Object.keys(schemaMap(document)).length;
44
+ if (!paths) {
45
+ return {
46
+ ok: false,
47
+ message: 'No usable cached OpenAPI document found. The latest cache exists, but it has no paths.',
48
+ versionsCount: versions.length,
49
+ latestVersion: metaSummary(meta),
50
+ importExamples,
51
+ };
52
+ }
53
+ return {
54
+ ok: true,
55
+ message: 'API Skill cache is ready.',
56
+ versionsCount: versions.length,
57
+ latestVersion: {
58
+ ...metaSummary(meta),
59
+ paths,
60
+ schemas,
61
+ },
62
+ importExamples,
63
+ };
64
+ } catch (error) {
65
+ return {
66
+ ok: false,
67
+ message: 'No usable cached OpenAPI document found. Import or crawl a document first.',
68
+ error: error instanceof Error ? error.message : 'Unknown cache read error',
69
+ versionsCount: versions.length,
70
+ latestVersion: versions.find((version) => version.latest) ?? versions[0],
71
+ importExamples,
72
+ };
73
+ }
74
+ }
75
+
76
+ export function formatCheckText(result) {
77
+ const lines = [];
78
+ if (result.ok) {
79
+ lines.push('OK: API Skill cache is ready.');
80
+ if (result.latestVersion?.versionId) lines.push(`Latest version: ${result.latestVersion.versionId}`);
81
+ lines.push(`Versions: ${result.versionsCount ?? 0}`);
82
+ lines.push(`Paths: ${result.latestVersion?.paths ?? 0}`);
83
+ lines.push(`Schemas: ${result.latestVersion?.schemas ?? 0}`);
84
+ return lines.join('\n');
85
+ }
86
+
87
+ lines.push('No usable cached OpenAPI document found.');
88
+ if (result.error) lines.push(`Reason: ${result.error}`);
89
+ lines.push('');
90
+ lines.push('Import a document first:');
91
+ lines.push('');
92
+ lines.push('Web:');
93
+ result.importExamples.web.forEach((example) => lines.push(` ${example}`));
94
+ lines.push('');
95
+ lines.push('CLI:');
96
+ result.importExamples.cli.forEach((example) => lines.push(` ${example}`));
97
+ lines.push('');
98
+ lines.push('MCP:');
99
+ result.importExamples.mcp.forEach((example) => lines.push(` ${example.tool} ${JSON.stringify(example.arguments)}`));
100
+ return lines.join('\n');
101
+ }
102
+
103
+ export async function listVersions({ limit = 20 } = {}) {
104
+ return listCachedVersions().then((versions) => versions.slice(0, clampLimit(limit, 20, 200)));
105
+ }
106
+
107
+ export async function searchEndpoints({ query = '', method = '', tag = '', versionId, limit = 20 } = {}) {
108
+ const { document, meta } = await readCachedDocument(versionId);
109
+ const normalizedQuery = String(query).trim().toLowerCase();
110
+ const normalizedMethod = String(method).trim().toLowerCase();
111
+ const normalizedTag = String(tag).trim();
112
+ const endpoints = listEndpoints(document)
113
+ .filter((endpoint) => !normalizedQuery || endpoint.searchable.includes(normalizedQuery))
114
+ .filter((endpoint) => !normalizedMethod || endpoint.method === normalizedMethod)
115
+ .filter((endpoint) => !normalizedTag || endpoint.tags.includes(normalizedTag))
116
+ .slice(0, clampLimit(limit, 20, 100))
117
+ .map(toEndpointSummary);
118
+
119
+ return {
120
+ version: metaSummary(meta),
121
+ count: endpoints.length,
122
+ endpoints,
123
+ };
124
+ }
125
+
126
+ export async function getEndpointDetails({ method, path, versionId, includeRaw = false } = {}) {
127
+ const normalizedMethod = requiredString({ method }, 'method').toLowerCase();
128
+ const normalizedPath = requiredString({ path }, 'path');
129
+ const { document, meta } = await readCachedDocument(versionId);
130
+ const endpoint = findEndpoint(document, normalizedMethod, normalizedPath);
131
+ if (!endpoint) throw new Error(`Endpoint not found: ${normalizedMethod.toUpperCase()} ${normalizedPath}`);
132
+ return endpointDetails(document, endpoint, meta, includeRaw);
133
+ }
134
+
135
+ export async function getAiContext({ method, path, versionId } = {}) {
136
+ const normalizedMethod = requiredString({ method }, 'method').toLowerCase();
137
+ const normalizedPath = requiredString({ path }, 'path');
138
+ const { document } = await readCachedDocument(versionId);
139
+ const endpoint = findEndpoint(document, normalizedMethod, normalizedPath);
140
+ if (!endpoint) throw new Error(`Endpoint not found: ${normalizedMethod.toUpperCase()} ${normalizedPath}`);
141
+ return buildAiMarkdown(document, endpoint);
142
+ }
143
+
144
+ export async function getSchemaDetails({ name, versionId } = {}) {
145
+ const schemaNameOrSuffix = requiredString({ name }, 'name');
146
+ const { document, meta } = await readCachedDocument(versionId);
147
+ const schemas = schemaMap(document);
148
+ const schemaName = Object.keys(schemas).find((key) => key === schemaNameOrSuffix) ?? Object.keys(schemas).find((key) => key.endsWith(schemaNameOrSuffix));
149
+ if (!schemaName) throw new Error(`Schema not found: ${schemaNameOrSuffix}`);
150
+ return {
151
+ version: metaSummary(meta),
152
+ name: schemaName,
153
+ fields: flattenSchema(document, schemas[schemaName]),
154
+ rawSchema: schemas[schemaName],
155
+ };
156
+ }
157
+
158
+ export async function queryApi({ pathOrKeyword, method = '', versionId, limit = 20, format = 'json' } = {}) {
159
+ const keyword = requiredString({ pathOrKeyword }, 'pathOrKeyword');
160
+ const { document, meta } = await readCachedDocument(versionId);
161
+ const result = queryEndpoints(document, keyword, { method, limit });
162
+ if (result.kind === 'single') {
163
+ if (format === 'cli') {
164
+ return {
165
+ kind: 'single',
166
+ version: metaSummary(meta),
167
+ cli: formatManualCli(endpointToManualConfig(result.endpoint, document)),
168
+ };
169
+ }
170
+ if (format === 'raw') {
171
+ return {
172
+ kind: 'single',
173
+ version: metaSummary(meta),
174
+ endpoint: result.endpoint,
175
+ };
176
+ }
177
+ return {
178
+ kind: 'single',
179
+ version: metaSummary(meta),
180
+ endpoint: endpointDetails(document, result.endpoint, meta, false),
181
+ };
182
+ }
183
+ return {
184
+ kind: 'multiple',
185
+ version: metaSummary(meta),
186
+ count: result.endpoints.length,
187
+ endpoints: result.endpoints.map(toEndpointSummary),
188
+ };
189
+ }
190
+
191
+ export async function importOpenApiUrl({ url, auth, versionId } = {}) {
192
+ const sourceUrl = requiredString({ url }, 'url');
193
+ const result = await importFromUrl(sourceUrl, { basicAuth: auth });
194
+ return saveImportedDocument({ document: result.document, mode: 'file', inputUrl: sourceUrl, resolvedUrl: result.resolvedUrl, versionId });
195
+ }
196
+
197
+ export async function crawlOpenApiUrl({ url, auth, versionId } = {}) {
198
+ const pageUrl = requiredString({ url }, 'url');
199
+ const result = await crawlOpenApi(pageUrl, { basicAuth: auth });
200
+ return saveImportedDocument({ document: result.document, mode: 'crawl', inputUrl: pageUrl, resolvedUrl: result.resolvedUrl, versionId });
201
+ }
202
+
203
+ export async function importOpenApiFile({ file, versionId } = {}) {
204
+ const filePath = requiredString({ file }, 'file');
205
+ const result = await importFromLocalFile(filePath);
206
+ return saveImportedDocument({ document: result.document, mode: 'upload', inputUrl: filePath, resolvedUrl: result.resolvedUrl, versionId });
207
+ }
208
+
209
+ export async function importOpenApiCurl({ curlText, curlFile, versionId } = {}) {
210
+ const text = curlFile ? await readFile(curlFile, 'utf8') : curlText;
211
+ const command = requiredString({ curlText: text }, 'curlText');
212
+ const result = await importFromCurl(command);
213
+ return saveImportedDocument({ document: result.document, mode: 'curl', inputUrl: command, resolvedUrl: result.resolvedUrl, versionId });
214
+ }
215
+
216
+ export async function createDocument({ title, version, description, environmentName, environmentBaseUrl } = {}) {
217
+ return createBlankDocument({ title, version, description, environmentName, environmentBaseUrl });
218
+ }
219
+
220
+ export async function createApi({ config, configText, versionId } = {}) {
221
+ const normalized = config ?? parseManualCliText(requiredString({ configText }, 'configText'));
222
+ return saveManualOperation({ versionId, config: normalized });
223
+ }
224
+
225
+ export async function editApi({ method, path, config, configText, versionId } = {}) {
226
+ const normalizedMethod = requiredString({ method }, 'method');
227
+ const normalizedPath = requiredString({ path }, 'path');
228
+ const targetVersionId = versionId || (await readCachedDocument()).meta?.versionId;
229
+ const normalized = config ?? parseManualCliText(requiredString({ configText }, 'configText'));
230
+ return saveManualOperation({ versionId: targetVersionId, config: normalized, replaceTarget: { method: normalizedMethod, path: normalizedPath } });
231
+ }
232
+
233
+ export async function deleteApi({ method, path, versionId } = {}) {
234
+ const normalizedMethod = requiredString({ method }, 'method');
235
+ const normalizedPath = requiredString({ path }, 'path');
236
+ const targetVersionId = versionId || (await readCachedDocument()).meta?.versionId;
237
+ return deleteManualOperation({ versionId: targetVersionId, method: normalizedMethod, path: normalizedPath });
238
+ }
239
+
240
+ export function queryEndpoints(document, pathOrKeyword, options = {}) {
241
+ const keyword = String(pathOrKeyword || '').trim();
242
+ const normalized = keyword.toLowerCase();
243
+ const method = String(options.method || '').trim().toLowerCase();
244
+ const limit = clampLimit(options.limit, 20, 200);
245
+ const endpoints = listEndpoints(document).filter((endpoint) => !method || endpoint.method === method);
246
+ const exactMatches = endpoints.filter((endpoint) => endpoint.path === keyword);
247
+ if (exactMatches.length === 1) return { kind: 'single', endpoint: exactMatches[0] };
248
+ if (exactMatches.length > 1) return { kind: 'multiple', endpoints: exactMatches.slice(0, limit) };
249
+
250
+ const fuzzyMatches = endpoints.filter((endpoint) => endpoint.path.includes(keyword) || endpoint.searchable.includes(normalized));
251
+ if (fuzzyMatches.length === 1) return { kind: 'single', endpoint: fuzzyMatches[0] };
252
+ if (!fuzzyMatches.length) throw new Error(`没有找到匹配接口: ${keyword}`);
253
+ return { kind: 'multiple', endpoints: fuzzyMatches.slice(0, limit) };
254
+ }
255
+
256
+ export function formatSavedResult(saved) {
257
+ return {
258
+ meta: saved.meta,
259
+ versionId: saved.meta.versionId,
260
+ paths: saved.meta.paths,
261
+ schemas: saved.meta.schemas,
262
+ file: saved.meta.savedPath,
263
+ };
264
+ }
265
+
266
+ function endpointDetails(document, endpoint, meta, includeRaw) {
267
+ const operation = endpoint.operation;
268
+ return {
269
+ version: metaSummary(meta),
270
+ method: endpoint.method.toUpperCase(),
271
+ path: endpoint.path,
272
+ summary: endpoint.summary,
273
+ tags: endpoint.tags,
274
+ parameters: parameterRows(document, operation.parameters),
275
+ requestBody: requestBodyRows(document, operation),
276
+ responses: responseRows(document, operation),
277
+ manualConfig: endpointToManualConfig(endpoint, document),
278
+ rawOperation: includeRaw ? operation : undefined,
279
+ };
280
+ }
281
+
282
+ function toEndpointSummary(endpoint) {
283
+ return {
284
+ method: endpoint.method.toUpperCase(),
285
+ path: endpoint.path,
286
+ summary: endpoint.summary,
287
+ tags: endpoint.tags,
288
+ hasBody: endpoint.hasBody,
289
+ };
290
+ }
291
+
292
+ function schemaMap(document) {
293
+ return document.components?.schemas ?? document.definitions ?? {};
294
+ }
295
+
296
+ function getSchemaName(ref) {
297
+ if (!ref) return '';
298
+ return decodeURIComponent(ref.split('/').pop() ?? ref);
299
+ }
300
+
301
+ function resolveSchema(document, schema, seen = new Set()) {
302
+ if (!schema) return undefined;
303
+ if (schema.$ref) {
304
+ if (seen.has(schema.$ref)) return { type: 'object', description: `循环引用: ${getSchemaName(schema.$ref)}` };
305
+ seen.add(schema.$ref);
306
+ const resolved = schema.$ref.startsWith('#/')
307
+ ? schema.$ref
308
+ .slice(2)
309
+ .split('/')
310
+ .reduce((current, key) => (current && typeof current === 'object' ? current[decodeURIComponent(key)] : undefined), document)
311
+ : undefined;
312
+ return resolveSchema(document, resolved, seen);
313
+ }
314
+ if (schema.allOf?.length) return mergeSchemas(document, schema.allOf, schema);
315
+ return schema;
316
+ }
317
+
318
+ function mergeSchemas(document, schemas, base) {
319
+ return schemas.reduce(
320
+ (merged, item) => {
321
+ const resolved = resolveSchema(document, item) ?? {};
322
+ return {
323
+ ...merged,
324
+ ...resolved,
325
+ description: [merged.description, resolved.description].filter(Boolean).join(';') || merged.description,
326
+ required: [...new Set([...(merged.required ?? []), ...(resolved.required ?? [])])],
327
+ properties: { ...(merged.properties ?? {}), ...(resolved.properties ?? {}) },
328
+ };
329
+ },
330
+ { ...base, allOf: undefined },
331
+ );
332
+ }
333
+
334
+ function schemaType(document, schema) {
335
+ if (!schema) return '-';
336
+ if (schema.$ref) return getSchemaName(schema.$ref);
337
+ const resolved = resolveSchema(document, schema) ?? schema;
338
+ if (resolved.enum?.length) return `${resolved.type ?? 'enum'}<${resolved.enum.map(formatValue).join(' | ')}>`;
339
+ if (resolved.type === 'array') return `${schemaType(document, resolved.items)}[]`;
340
+ if (resolved.anyOf?.length) return resolved.anyOf.map((item) => schemaType(document, item)).join(' | ');
341
+ if (resolved.oneOf?.length) return resolved.oneOf.map((item) => schemaType(document, item)).join(' | ');
342
+ if (resolved.additionalProperties && typeof resolved.additionalProperties === 'object') {
343
+ return `Record<string, ${schemaType(document, resolved.additionalProperties)}>`;
344
+ }
345
+ return [resolved.type, resolved.format].filter(Boolean).join(':') || 'object';
346
+ }
347
+
348
+ function flattenSchema(document, schema, options = {}) {
349
+ const depth = options.depth ?? 0;
350
+ if (!schema || depth > MAX_DEPTH) return [];
351
+ if (schema.$ref) {
352
+ if (options.seen?.has(schema.$ref)) {
353
+ return [fieldRow(options.prefix || getSchemaName(schema.$ref), options.prefix || getSchemaName(schema.$ref), getSchemaName(schema.$ref), '循环引用,已停止展开', false, depth, options.location)];
354
+ }
355
+ const nextSeen = new Set(options.seen ?? []);
356
+ nextSeen.add(schema.$ref);
357
+ return flattenSchema(document, resolveSchema(document, schema), { ...options, seen: nextSeen });
358
+ }
359
+
360
+ const resolved = resolveSchema(document, schema) ?? schema;
361
+ const properties = resolved.properties ?? {};
362
+ const rows = [];
363
+ const requiredFields = resolved.required ?? options.requiredFields ?? [];
364
+ if (resolved.type === 'array') {
365
+ const name = options.prefix || '[]';
366
+ rows.push(toRow(document, name, name, resolved, false, depth, options.location));
367
+ rows.push(...flattenSchema(document, resolved.items, { ...options, prefix: `${name}[]`, depth: depth + 1 }));
368
+ return rows;
369
+ }
370
+ if (!Object.keys(properties).length) {
371
+ if (options.prefix) rows.push(toRow(document, options.prefix.split('.').pop() ?? options.prefix, options.prefix, resolved, false, depth, options.location));
372
+ return rows;
373
+ }
374
+ Object.entries(properties).forEach(([name, property]) => {
375
+ const path = options.prefix ? `${options.prefix}.${name}` : name;
376
+ const child = resolveSchema(document, property) ?? property;
377
+ rows.push(toRow(document, name, path, property, requiredFields.includes(name), depth, options.location));
378
+ const nested = child.type === 'array' || Boolean(child.properties && Object.keys(child.properties).length) || Boolean(child.$ref);
379
+ if (nested) {
380
+ rows.push(...flattenSchema(document, property, { ...options, prefix: child.type === 'array' ? `${path}[]` : path, requiredFields: child.required, depth: depth + 1 }));
381
+ }
382
+ });
383
+ return rows;
384
+ }
385
+
386
+ function fieldRow(name, path, type, description, required, depth, location) {
387
+ return { name, path, location, required, type, description, defaultValue: '', enumValue: '', depth };
388
+ }
389
+
390
+ function toRow(document, name, path, schema, required, depth, location) {
391
+ const resolved = resolveSchema(document, schema) ?? schema;
392
+ return {
393
+ name,
394
+ path,
395
+ location,
396
+ required,
397
+ type: schemaType(document, schema),
398
+ description: schema?.description?.trim() || resolved.description?.trim() || '',
399
+ defaultValue: resolved.default === undefined ? '' : formatValue(resolved.default),
400
+ enumValue: resolved.enum?.map(formatValue).join(', ') ?? '',
401
+ depth,
402
+ };
403
+ }
404
+
405
+ function parameterRows(document, parameters = []) {
406
+ return parameters.map((parameter) => ({
407
+ name: parameter.name,
408
+ path: parameter.name,
409
+ location: parameter.in,
410
+ required: Boolean(parameter.required),
411
+ type: schemaType(document, parameter.schema ?? parameter),
412
+ description: parameter.description || parameter.schema?.description || '',
413
+ defaultValue: parameter.schema?.default === undefined ? '' : formatValue(parameter.schema.default),
414
+ enumValue: parameter.schema?.enum?.map(formatValue).join(', ') ?? '',
415
+ depth: 0,
416
+ }));
417
+ }
418
+
419
+ function requestBodyRows(document, operation) {
420
+ return Object.fromEntries(
421
+ Object.entries(operation.requestBody?.content ?? {}).map(([mediaType, media]) => [
422
+ mediaType,
423
+ flattenSchema(document, media.schema).map((row) => ({ ...row, location: 'body' })),
424
+ ]),
425
+ );
426
+ }
427
+
428
+ function responseRows(document, operation) {
429
+ return Object.fromEntries(
430
+ Object.entries(operation.responses ?? {}).map(([status, response]) => [
431
+ status,
432
+ Object.fromEntries(
433
+ Object.entries(response.content ?? {}).map(([mediaType, media]) => [
434
+ mediaType,
435
+ flattenSchema(document, media.schema).map((row) => ({ ...row, location: `response ${status}` })),
436
+ ]),
437
+ ),
438
+ ]),
439
+ );
440
+ }
441
+
442
+ function buildAiMarkdown(document, endpoint) {
443
+ const operation = endpoint.operation;
444
+ const parameterText = rowsToMarkdown(parameterRows(document, operation.parameters), '无参数');
445
+ const requestBodies = requestBodyRows(document, operation);
446
+ const responses = responseRows(document, operation);
447
+ const requestText = Object.keys(requestBodies).length
448
+ ? Object.entries(requestBodies)
449
+ .map(([mediaType, rows]) => `### Request Body: ${mediaType}\n${rowsToMarkdown(rows, '空对象或无字段说明')}`)
450
+ .join('\n\n')
451
+ : '### Request Body\n无请求体';
452
+ const responseText = Object.entries(operation.responses ?? {})
453
+ .map(([status, response]) => {
454
+ const medias = responses[status] ?? {};
455
+ const rows = Object.entries(medias);
456
+ if (!rows.length) return `### Response ${status}\n- Description: ${response.description || '无'}\n- Fields: 无字段说明`;
457
+ return rows.map(([mediaType, fields]) => `### Response ${status}: ${mediaType}\n- Description: ${response.description || '无'}\n${rowsToMarkdown(fields, '空对象或无字段说明')}`).join('\n\n');
458
+ })
459
+ .join('\n\n');
460
+
461
+ return [
462
+ `# API: ${endpoint.method.toUpperCase()} ${endpoint.path}`,
463
+ `- Summary: ${endpoint.summary}`,
464
+ `- Tags: ${endpoint.tags.join(', ')}`,
465
+ operation.operationId ? `- OperationId: ${operation.operationId}` : '',
466
+ operation.description ? `- Description: ${operation.description}` : '',
467
+ '',
468
+ '## Request Parameters',
469
+ parameterText,
470
+ '',
471
+ requestText,
472
+ '',
473
+ '## Responses',
474
+ responseText || '无响应说明',
475
+ ]
476
+ .filter((line) => line !== '')
477
+ .join('\n');
478
+ }
479
+
480
+ function rowsToMarkdown(rows, emptyText) {
481
+ if (!rows.length) return emptyText;
482
+ return [
483
+ '| 字段 | 位置 | 必填 | 类型 | 默认值 | 枚举 | 说明 |',
484
+ '| --- | --- | --- | --- | --- | --- | --- |',
485
+ ...rows.map((row) => `| ${escapeCell(row.depth > 0 ? `${' '.repeat(row.depth)}${row.name}` : row.name)} | ${escapeCell(row.location || '-')} | ${row.required ? '是' : '否'} | ${escapeCell(row.type)} | ${escapeCell(row.defaultValue || '-')} | ${escapeCell(row.enumValue || '-')} | ${escapeCell(row.description || '-')} |`),
486
+ ].join('\n');
487
+ }
488
+
489
+ function metaSummary(meta) {
490
+ if (!meta) return {};
491
+ return {
492
+ versionId: meta.versionId,
493
+ savedAt: meta.savedAt,
494
+ inputUrl: meta.inputUrl,
495
+ resolvedUrl: meta.resolvedUrl,
496
+ paths: meta.paths,
497
+ schemas: meta.schemas,
498
+ };
499
+ }
500
+
501
+ function requiredString(args, key) {
502
+ const value = args?.[key];
503
+ if (typeof value !== 'string' || !value.trim()) throw new Error(`Missing required argument: ${key}`);
504
+ return value.trim();
505
+ }
506
+
507
+ function clampLimit(value, fallback, max) {
508
+ const numeric = Number(value);
509
+ if (!Number.isFinite(numeric) || numeric <= 0) return fallback;
510
+ return Math.min(Math.floor(numeric), max);
511
+ }
512
+
513
+ function formatValue(value) {
514
+ if (typeof value === 'string') return value;
515
+ return JSON.stringify(value);
516
+ }
517
+
518
+ function escapeCell(value) {
519
+ return String(value).replace(/\|/g, '\\|').replace(/\n/g, '<br />');
520
+ }