agent-first-data 0.12.2 → 0.13.1
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/README.md +26 -557
- package/dist/afdata_logging.d.ts +7 -3
- package/dist/afdata_logging.js +16 -14
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +63 -4
- package/dist/format.d.ts +12 -7
- package/dist/format.js +149 -81
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/skill.js +155 -43
- package/package.json +2 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent-First Data (AFDATA) — suffix-driven output formatting and protocol templates.
|
|
3
3
|
*/
|
|
4
|
-
export { buildJsonOk, buildJsonError, buildJson, type JsonValue, RedactionPolicy, type RedactionOptions, OutputStyle, type OutputOptions, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions,
|
|
4
|
+
export { buildJsonOk, buildJsonError, buildJson, type JsonValue, RedactionPolicy, type RedactionOptions, OutputStyle, type OutputOptions, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, redactSecretsInPlace, redactSecretsInPlaceWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, redactUrlSecrets, redactUrlSecretsWithOptions, parseSize, normalizeUtcOffset, } from "./format.js";
|
|
5
5
|
export { log, span, initJson, initPlain, initYaml } from "./afdata_logging.js";
|
|
6
|
-
export { type OutputFormat, cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, } from "./cli.js";
|
|
6
|
+
export { type OutputFormat, cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, buildCliVersion, cliRenderVersion, cliHandleVersionOrContinue, } from "./cli.js";
|
|
7
7
|
export { type SkillSpec, type SkillAction, type SkillScope, type SkillAgentSelection, type SkillAgent, type SkillOptions, type SkillTargetStatus, type SkillUninstallStatus, type SkillReport, SkillError, runSkillAdmin, } from "./skill.js";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent-First Data (AFDATA) — suffix-driven output formatting and protocol templates.
|
|
3
3
|
*/
|
|
4
|
-
export { buildJsonOk, buildJsonError, buildJson, RedactionPolicy, OutputStyle, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions,
|
|
4
|
+
export { buildJsonOk, buildJsonError, buildJson, RedactionPolicy, OutputStyle, outputJson, outputJsonWith, outputJsonWithOptions, outputYaml, outputYamlWithOptions, outputPlain, outputPlainWithOptions, redactSecretsInPlace, redactSecretsInPlaceWithOptions, redactedValue, redactedValueWith, redactedValueWithOptions, redactUrlSecrets, redactUrlSecretsWithOptions, parseSize, normalizeUtcOffset, } from "./format.js";
|
|
5
5
|
export { log, span, initJson, initPlain, initYaml } from "./afdata_logging.js";
|
|
6
|
-
export { cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, } from "./cli.js";
|
|
6
|
+
export { cliParseOutput, cliParseLogFilters, cliOutput, cliOutputWithOptions, buildCliError, buildCliVersion, cliRenderVersion, cliHandleVersionOrContinue, } from "./cli.js";
|
|
7
7
|
export { SkillError, runSkillAdmin, } from "./skill.js";
|
package/dist/skill.js
CHANGED
|
@@ -10,8 +10,11 @@
|
|
|
10
10
|
* stdout/stderr itself.
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { basename, join } from "node:path";
|
|
14
14
|
const SKILL_FILE_NAME = "SKILL.md";
|
|
15
|
+
const FNV1A64_OFFSET = 0xcbf29ce484222325n;
|
|
16
|
+
const FNV1A64_PRIME = 0x100000001b3n;
|
|
17
|
+
const FNV1A64_MASK = 0xffffffffffffffffn;
|
|
15
18
|
/** A skill admin failure with an operator-facing message and optional hint. */
|
|
16
19
|
export class SkillError extends Error {
|
|
17
20
|
hint;
|
|
@@ -27,6 +30,7 @@ export class SkillError extends Error {
|
|
|
27
30
|
* {@link SkillError}. Does not touch stdout/stderr.
|
|
28
31
|
*/
|
|
29
32
|
export function runSkillAdmin(spec, action, options) {
|
|
33
|
+
validateSpec(spec);
|
|
30
34
|
switch (action) {
|
|
31
35
|
case "status":
|
|
32
36
|
return status(spec, options);
|
|
@@ -57,10 +61,18 @@ function install(spec, options) {
|
|
|
57
61
|
const installed = [];
|
|
58
62
|
for (const target of targets) {
|
|
59
63
|
fs.mkdirSync(target.skillDir, { recursive: true });
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
const st = lstatOrNull(target.skillPath);
|
|
65
|
+
if (st !== null) {
|
|
66
|
+
if (st.isSymbolicLink()) {
|
|
67
|
+
if (!options.force) {
|
|
68
|
+
throw new SkillError(`refusing to overwrite symlinked skill at ${target.skillPath}`, "pass --force to replace the symlink itself, or choose another --skills-dir");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else if (!isManagedOrBundledSkill(spec, target.skillPath) && !options.force) {
|
|
72
|
+
throw new SkillError(`refusing to overwrite unmanaged skill at ${target.skillPath}`, "pass --force to replace it, or choose another --skills-dir");
|
|
73
|
+
}
|
|
62
74
|
}
|
|
63
|
-
|
|
75
|
+
writeSkillAtomic(target, content);
|
|
64
76
|
validateInstalledSkill(spec, target.skillPath);
|
|
65
77
|
installed.push(targetStatus(spec, target));
|
|
66
78
|
}
|
|
@@ -76,11 +88,17 @@ function uninstall(spec, options) {
|
|
|
76
88
|
const targets = resolveTargets(spec, options);
|
|
77
89
|
const removed = [];
|
|
78
90
|
for (const target of targets) {
|
|
79
|
-
|
|
91
|
+
const st = lstatOrNull(target.skillPath);
|
|
92
|
+
if (st === null) {
|
|
80
93
|
removed.push(targetUninstallStatus(target, false));
|
|
81
94
|
continue;
|
|
82
95
|
}
|
|
83
|
-
if (
|
|
96
|
+
if (st.isSymbolicLink()) {
|
|
97
|
+
if (!options.force) {
|
|
98
|
+
throw new SkillError(`refusing to remove symlinked skill at ${target.skillPath}`, "pass --force to remove the symlink itself");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else if (!isManagedOrBundledSkill(spec, target.skillPath) && !options.force) {
|
|
84
102
|
throw new SkillError(`refusing to remove unmanaged skill at ${target.skillPath}`, `only skills generated by ${spec.markerSlug} skill install can be removed without --force`);
|
|
85
103
|
}
|
|
86
104
|
fs.rmSync(target.skillPath);
|
|
@@ -172,16 +190,20 @@ function defaultSkillsDir(agent, scope) {
|
|
|
172
190
|
return join(homeDir(), ".config", "opencode", "skills");
|
|
173
191
|
}
|
|
174
192
|
function targetStatus(spec, target) {
|
|
175
|
-
const
|
|
193
|
+
const st = lstatOrNull(target.skillPath);
|
|
194
|
+
const installed = st !== null;
|
|
176
195
|
let valid = false;
|
|
177
196
|
let current = false;
|
|
178
197
|
let managed = false;
|
|
179
198
|
let validationError = null;
|
|
180
|
-
if (
|
|
199
|
+
if (st?.isSymbolicLink()) {
|
|
200
|
+
validationError = "target SKILL.md is a symlink; refusing to follow it";
|
|
201
|
+
}
|
|
202
|
+
else if (st?.isFile()) {
|
|
181
203
|
const text = fs.readFileSync(target.skillPath, "utf-8");
|
|
182
204
|
managed = skillTextIsManagedOrBundled(spec, text);
|
|
183
|
-
current = normalizeSkillText(spec, text) === normalizeSkillText(spec, spec.source);
|
|
184
|
-
const error =
|
|
205
|
+
current = !skillTextHasLegacyMarker(spec, text) && normalizeSkillText(spec, text) === normalizeSkillText(spec, spec.source);
|
|
206
|
+
const error = validateSkillTextError(spec, text);
|
|
185
207
|
if (error === null) {
|
|
186
208
|
valid = true;
|
|
187
209
|
}
|
|
@@ -189,6 +211,9 @@ function targetStatus(spec, target) {
|
|
|
189
211
|
validationError = error;
|
|
190
212
|
}
|
|
191
213
|
}
|
|
214
|
+
else if (installed) {
|
|
215
|
+
validationError = "target SKILL.md is not a regular file";
|
|
216
|
+
}
|
|
192
217
|
return {
|
|
193
218
|
agent: target.agent,
|
|
194
219
|
scope: target.scope,
|
|
@@ -216,9 +241,30 @@ function generatedBy(spec) {
|
|
|
216
241
|
function marker(spec) {
|
|
217
242
|
return `${spec.markerSlug}-managed-skill: true`;
|
|
218
243
|
}
|
|
244
|
+
function sourceHash(spec) {
|
|
245
|
+
let hash = FNV1A64_OFFSET;
|
|
246
|
+
for (const byte of Buffer.from(spec.source, "utf-8")) {
|
|
247
|
+
hash ^= BigInt(byte);
|
|
248
|
+
hash = (hash * FNV1A64_PRIME) & FNV1A64_MASK;
|
|
249
|
+
}
|
|
250
|
+
return hash.toString(16).padStart(16, "0");
|
|
251
|
+
}
|
|
252
|
+
function managedMarkerBlock(spec) {
|
|
253
|
+
const slug = spec.markerSlug;
|
|
254
|
+
return [
|
|
255
|
+
"<!--",
|
|
256
|
+
generatedBy(spec),
|
|
257
|
+
`${slug}-managed-skill: true`,
|
|
258
|
+
`${slug}-managed-skill-name: ${spec.name}`,
|
|
259
|
+
`${slug}-managed-skill-source-hash-fnv1a64: ${sourceHash(spec)}`,
|
|
260
|
+
"-->",
|
|
261
|
+
].join("\n");
|
|
262
|
+
}
|
|
263
|
+
function legacyMarkerBlock(spec) {
|
|
264
|
+
return `<!-- ${generatedBy(spec)} -->\n<!-- ${marker(spec)} -->`;
|
|
265
|
+
}
|
|
219
266
|
function managedSkillContents(spec) {
|
|
220
|
-
const
|
|
221
|
-
const mark = marker(spec);
|
|
267
|
+
const block = managedMarkerBlock(spec);
|
|
222
268
|
const lines = splitLines(spec.source);
|
|
223
269
|
const out = [];
|
|
224
270
|
let inserted = false;
|
|
@@ -227,12 +273,12 @@ function managedSkillContents(spec) {
|
|
|
227
273
|
if (i === 0)
|
|
228
274
|
return;
|
|
229
275
|
if (!inserted && line.trim() === "---") {
|
|
230
|
-
out.push(
|
|
276
|
+
out.push(block, "");
|
|
231
277
|
inserted = true;
|
|
232
278
|
}
|
|
233
279
|
});
|
|
234
280
|
if (!inserted) {
|
|
235
|
-
out.push(
|
|
281
|
+
out.push(block);
|
|
236
282
|
}
|
|
237
283
|
return out.map((line) => line + "\n").join("");
|
|
238
284
|
}
|
|
@@ -240,21 +286,37 @@ function validateInstalledSkill(spec, path) {
|
|
|
240
286
|
validateSkillText(spec, fs.readFileSync(path, "utf-8"));
|
|
241
287
|
}
|
|
242
288
|
function validateSkillText(spec, text) {
|
|
243
|
-
const error =
|
|
289
|
+
const error = validateSkillTextError(spec, text);
|
|
244
290
|
if (error !== null) {
|
|
245
|
-
|
|
291
|
+
const hint = error.includes("does not match expected")
|
|
292
|
+
? `set front matter name to ${spec.name}`
|
|
293
|
+
: "quote scalar values that contain ': ', especially description";
|
|
294
|
+
throw new SkillError(`invalid ${spec.title} skill front matter: ${error}`, hint);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function validateSkillTextError(spec, text) {
|
|
298
|
+
const parsed = parseSkillFrontmatter(text);
|
|
299
|
+
if (parsed.error !== null) {
|
|
300
|
+
return parsed.error;
|
|
246
301
|
}
|
|
302
|
+
if (parsed.name !== spec.name) {
|
|
303
|
+
return `name ${JSON.stringify(parsed.name)} does not match expected ${JSON.stringify(spec.name)}`;
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
247
306
|
}
|
|
248
307
|
function validateSkillFrontmatter(text) {
|
|
308
|
+
return parseSkillFrontmatter(text).error;
|
|
309
|
+
}
|
|
310
|
+
function parseSkillFrontmatter(text) {
|
|
249
311
|
const lines = splitLines(text);
|
|
250
312
|
if (lines.length === 0) {
|
|
251
|
-
return "missing YAML front matter";
|
|
313
|
+
return { error: "missing YAML front matter" };
|
|
252
314
|
}
|
|
253
315
|
if (lines[0].trim() !== "---") {
|
|
254
|
-
return "missing opening --- YAML front matter delimiter";
|
|
316
|
+
return { error: "missing opening --- YAML front matter delimiter" };
|
|
255
317
|
}
|
|
256
318
|
let foundEnd = false;
|
|
257
|
-
let
|
|
319
|
+
let name;
|
|
258
320
|
let hasDescription = false;
|
|
259
321
|
for (let idx = 1; idx < lines.length; idx++) {
|
|
260
322
|
const line = lines[idx];
|
|
@@ -268,67 +330,84 @@ function validateSkillFrontmatter(text) {
|
|
|
268
330
|
continue;
|
|
269
331
|
}
|
|
270
332
|
if (line.startsWith(" ") || line.startsWith("\t")) {
|
|
271
|
-
return `line ${lineNo}: nested YAML is not supported here
|
|
333
|
+
return { error: `line ${lineNo}: nested YAML is not supported here` };
|
|
272
334
|
}
|
|
273
335
|
const colon = line.indexOf(":");
|
|
274
336
|
if (colon === -1) {
|
|
275
|
-
return `line ${lineNo}: expected key: value
|
|
337
|
+
return { error: `line ${lineNo}: expected key: value` };
|
|
276
338
|
}
|
|
277
339
|
const key = line.slice(0, colon).trim();
|
|
278
340
|
if (key === "") {
|
|
279
|
-
return `line ${lineNo}: empty key
|
|
341
|
+
return { error: `line ${lineNo}: empty key` };
|
|
280
342
|
}
|
|
281
343
|
const value = line.slice(colon + 1).replace(/^\s+/, "");
|
|
282
344
|
if (key === "name")
|
|
283
|
-
|
|
345
|
+
name = parseFrontmatterScalar(value);
|
|
284
346
|
if (key === "description")
|
|
285
347
|
hasDescription = true;
|
|
286
348
|
if (value.startsWith('"') || value.startsWith("'")) {
|
|
287
349
|
continue;
|
|
288
350
|
}
|
|
289
351
|
if (value.includes(": ")) {
|
|
290
|
-
return `line ${lineNo}: unquoted scalar contains ': '; quote the value
|
|
352
|
+
return { error: `line ${lineNo}: unquoted scalar contains ': '; quote the value` };
|
|
291
353
|
}
|
|
292
354
|
}
|
|
293
355
|
if (!foundEnd) {
|
|
294
|
-
return "missing closing --- YAML front matter delimiter";
|
|
356
|
+
return { error: "missing closing --- YAML front matter delimiter" };
|
|
295
357
|
}
|
|
296
|
-
if (
|
|
297
|
-
return "missing required name field";
|
|
358
|
+
if (name === undefined) {
|
|
359
|
+
return { error: "missing required name field" };
|
|
298
360
|
}
|
|
299
361
|
if (!hasDescription) {
|
|
300
|
-
return "missing required description field";
|
|
362
|
+
return { error: "missing required description field" };
|
|
301
363
|
}
|
|
302
|
-
return null;
|
|
364
|
+
return { name, error: null };
|
|
365
|
+
}
|
|
366
|
+
function parseFrontmatterScalar(value) {
|
|
367
|
+
value = value.trim();
|
|
368
|
+
if (value.length >= 2) {
|
|
369
|
+
const first = value[0];
|
|
370
|
+
const last = value[value.length - 1];
|
|
371
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
372
|
+
return value.slice(1, -1);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return value;
|
|
303
376
|
}
|
|
304
377
|
function isManagedOrBundledSkill(spec, path) {
|
|
305
|
-
|
|
378
|
+
const st = lstatOrNull(path);
|
|
379
|
+
if (st === null) {
|
|
306
380
|
return false;
|
|
307
381
|
}
|
|
382
|
+
if (st.isSymbolicLink()) {
|
|
383
|
+
throw new SkillError(`refusing to inspect symlinked skill at ${path}`, "pass --force to replace or remove the symlink itself");
|
|
384
|
+
}
|
|
308
385
|
return skillTextIsManagedOrBundled(spec, fs.readFileSync(path, "utf-8"));
|
|
309
386
|
}
|
|
310
387
|
function skillTextIsManagedOrBundled(spec, text) {
|
|
311
|
-
|
|
312
|
-
const gen = generatedBy(spec);
|
|
313
|
-
if (text.includes(mark) && text.includes(gen)) {
|
|
388
|
+
if (skillTextHasCurrentMarker(spec, text) || skillTextHasLegacyMarker(spec, text)) {
|
|
314
389
|
return true;
|
|
315
390
|
}
|
|
316
391
|
return normalizeSkillText(spec, text) === normalizeSkillText(spec, spec.source);
|
|
317
392
|
}
|
|
393
|
+
function skillTextHasCurrentMarker(spec, text) {
|
|
394
|
+
return text.replace(/\r\n/g, "\n").includes(managedMarkerBlock(spec));
|
|
395
|
+
}
|
|
396
|
+
function skillTextHasLegacyMarker(spec, text) {
|
|
397
|
+
return text.replace(/\r\n/g, "\n").includes(legacyMarkerBlock(spec));
|
|
398
|
+
}
|
|
318
399
|
/**
|
|
319
|
-
* Drop the managed-marker
|
|
400
|
+
* Drop the managed-marker blocks, then collapse runs of blank lines to one so that the
|
|
320
401
|
* blank line `managedSkillContents` inserts after the marker block does not make a
|
|
321
402
|
* managed install compare unequal to the bundled source.
|
|
322
403
|
*/
|
|
323
404
|
function normalizeSkillText(spec, text) {
|
|
324
|
-
|
|
325
|
-
|
|
405
|
+
text = text.replace(/\r\n/g, "\n");
|
|
406
|
+
text = text.replace(managedMarkerBlock(spec), "");
|
|
407
|
+
text = text.replace(legacyMarkerBlock(spec), "");
|
|
326
408
|
const out = [];
|
|
327
409
|
for (const line of splitLines(text)) {
|
|
328
410
|
const trimmed = line.trim();
|
|
329
|
-
if (trimmed.includes(mark) || trimmed.includes(gen)) {
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
411
|
if (trimmed === "" && out.length > 0 && out[out.length - 1].trim() === "") {
|
|
333
412
|
continue;
|
|
334
413
|
}
|
|
@@ -352,14 +431,47 @@ function expandTilde(input) {
|
|
|
352
431
|
}
|
|
353
432
|
return input;
|
|
354
433
|
}
|
|
434
|
+
function validateSpec(spec) {
|
|
435
|
+
validateSlug("skill name", spec.name);
|
|
436
|
+
validateSlug("marker slug", spec.markerSlug);
|
|
437
|
+
validateSkillText(spec, spec.source);
|
|
438
|
+
}
|
|
439
|
+
function validateSlug(field, value) {
|
|
440
|
+
if (slugIsValid(value)) {
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
throw new SkillError(`invalid ${field} ${JSON.stringify(value)}: expected a lowercase slug matching [a-z0-9][a-z0-9-]*[a-z0-9]`, "use lowercase ASCII letters, digits, and single hyphen-separated words");
|
|
444
|
+
}
|
|
445
|
+
function slugIsValid(value) {
|
|
446
|
+
if (value.length === 0)
|
|
447
|
+
return false;
|
|
448
|
+
const isLowerAlnum = (ch) => (ch >= "a" && ch <= "z") || (ch >= "0" && ch <= "9");
|
|
449
|
+
return isLowerAlnum(value[0]) && isLowerAlnum(value[value.length - 1]) &&
|
|
450
|
+
[...value].every((ch) => isLowerAlnum(ch) || ch === "-");
|
|
451
|
+
}
|
|
452
|
+
function lstatOrNull(path) {
|
|
453
|
+
return fs.lstatSync(path, { throwIfNoEntry: false }) ?? null;
|
|
454
|
+
}
|
|
455
|
+
function writeSkillAtomic(target, content) {
|
|
456
|
+
const tmp = join(target.skillDir, `.${basename(SKILL_FILE_NAME)}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
|
|
457
|
+
try {
|
|
458
|
+
fs.writeFileSync(tmp, content);
|
|
459
|
+
fs.renameSync(tmp, target.skillPath);
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
try {
|
|
463
|
+
fs.rmSync(tmp, { force: true });
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// ignore cleanup errors
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
355
471
|
/**
|
|
356
472
|
* Mirror Rust's `str::lines`: split on `\n` (normalizing `\r\n`), dropping a single
|
|
357
473
|
* trailing empty element from a final newline.
|
|
358
474
|
*/
|
|
359
|
-
function isFile(p) {
|
|
360
|
-
const st = fs.statSync(p, { throwIfNoEntry: false });
|
|
361
|
-
return st !== undefined && st.isFile();
|
|
362
|
-
}
|
|
363
475
|
function splitLines(s) {
|
|
364
476
|
const lines = s.replace(/\r\n/g, "\n").split("\n");
|
|
365
477
|
if (lines.length > 0 && lines[lines.length - 1] === "") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-first-data",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "A naming convention that lets AI agents understand your data without being told what it means.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
28
|
"build": "tsc",
|
|
29
|
+
"prepack": "npm run build",
|
|
29
30
|
"test": "tsx --test src/*.test.ts examples/agent_cli.ts"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|