agent-json-validate 1.2.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/validate.js ADDED
@@ -0,0 +1,863 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * agent.json validator
5
+ *
6
+ * Validates an agent.json manifest against the specification.
7
+ *
8
+ * Usage:
9
+ * npx agent-json-validate https://example.com/.well-known/agent.json
10
+ * npx agent-json-validate ./path/to/agent.json
11
+ * cat agent.json | npx agent-json-validate --stdin
12
+ */
13
+
14
+ const fs = require("fs");
15
+ const path = require("path");
16
+ const https = require("https");
17
+ const http = require("http");
18
+
19
+ // --- Schema constraints (inlined to avoid runtime dependency on ajv) ---
20
+
21
+ const VALID_PARAM_TYPES = [
22
+ "string",
23
+ "integer",
24
+ "number",
25
+ "boolean",
26
+ "array",
27
+ "object",
28
+ ];
29
+ const VALID_BOUNTY_TYPES = ["cpa"];
30
+ const VALID_CURRENCIES = ["USDC"];
31
+ const INTENT_NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
32
+ const ORIGIN_PATTERN =
33
+ /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
34
+
35
+ // --- Validation ---
36
+
37
+ function validate(manifest, sourceUrl) {
38
+ const errors = [];
39
+ const warnings = [];
40
+
41
+ // Root required fields
42
+ if (typeof manifest !== "object" || manifest === null || Array.isArray(manifest)) {
43
+ errors.push("Manifest must be a JSON object");
44
+ return { valid: false, errors, warnings };
45
+ }
46
+
47
+ // version
48
+ if (!manifest.version) {
49
+ errors.push('Missing required field: "version"');
50
+ } else if (manifest.version !== "1.0" && manifest.version !== "1.1") {
51
+ errors.push(`Invalid version: "${manifest.version}". Expected "1.0" or "1.1"`);
52
+ }
53
+
54
+ // origin
55
+ if (!manifest.origin) {
56
+ errors.push('Missing required field: "origin"');
57
+ } else if (typeof manifest.origin !== "string") {
58
+ errors.push('"origin" must be a string');
59
+ } else if (!ORIGIN_PATTERN.test(manifest.origin)) {
60
+ errors.push(
61
+ `Invalid origin: "${manifest.origin}". Must be a valid domain name.`
62
+ );
63
+ } else if (sourceUrl) {
64
+ try {
65
+ const url = new URL(sourceUrl);
66
+ const expectedOrigin = url.hostname;
67
+ if (
68
+ manifest.origin !== expectedOrigin &&
69
+ !expectedOrigin.endsWith("." + manifest.origin)
70
+ ) {
71
+ errors.push(
72
+ `Origin mismatch: manifest says "${manifest.origin}" but served from "${expectedOrigin}"`
73
+ );
74
+ }
75
+ } catch {
76
+ // Can't parse URL, skip origin check
77
+ }
78
+ }
79
+
80
+ // payout_address
81
+ if (!manifest.payout_address) {
82
+ errors.push('Missing required field: "payout_address"');
83
+ } else if (typeof manifest.payout_address !== "string") {
84
+ errors.push('"payout_address" must be a string');
85
+ } else if (manifest.payout_address.length === 0) {
86
+ errors.push('"payout_address" must not be empty');
87
+ }
88
+
89
+ // display_name (optional)
90
+ if (manifest.display_name !== undefined) {
91
+ if (typeof manifest.display_name !== "string") {
92
+ errors.push('"display_name" must be a string');
93
+ } else if (manifest.display_name.length > 100) {
94
+ warnings.push(
95
+ `"display_name" is ${manifest.display_name.length} characters (recommended max: 100)`
96
+ );
97
+ }
98
+ }
99
+
100
+ // description (optional)
101
+ if (manifest.description !== undefined) {
102
+ if (typeof manifest.description !== "string") {
103
+ errors.push('"description" must be a string');
104
+ } else if (manifest.description.length > 500) {
105
+ warnings.push(
106
+ `"description" is ${manifest.description.length} characters (recommended max: 500)`
107
+ );
108
+ }
109
+ }
110
+
111
+ if (manifest.extensions !== undefined) {
112
+ if (typeof manifest.extensions !== "object" || manifest.extensions === null || Array.isArray(manifest.extensions)) {
113
+ errors.push('"extensions" must be an object');
114
+ }
115
+ }
116
+
117
+ // identity (optional)
118
+ if (manifest.identity !== undefined) {
119
+ validateIdentity(manifest.identity, errors, warnings);
120
+ }
121
+
122
+ // bounty (optional, manifest-level)
123
+ if (manifest.bounty !== undefined) {
124
+ validateBounty(manifest.bounty, "manifest-level", errors, warnings);
125
+ }
126
+
127
+ // incentive (optional, manifest-level)
128
+ if (manifest.incentive !== undefined) {
129
+ validateIncentive(manifest.incentive, "manifest-level", errors, warnings);
130
+ }
131
+
132
+ // x402 (optional, root-level)
133
+ if (manifest.x402 !== undefined) {
134
+ validateX402Root(manifest.x402, "x402", errors, warnings);
135
+ }
136
+
137
+ // intents (optional)
138
+ if (manifest.intents !== undefined) {
139
+ if (!Array.isArray(manifest.intents)) {
140
+ errors.push('"intents" must be an array');
141
+ } else {
142
+ const intentNames = new Set();
143
+ manifest.intents.forEach((intent, i) => {
144
+ validateIntent(intent, i, intentNames, errors, warnings);
145
+ });
146
+ }
147
+ } else {
148
+ warnings.push(
149
+ "No intents declared. This is a Tier 1 (minimal) manifest. Declare intents for better agent routing."
150
+ );
151
+ }
152
+
153
+ // Cross-reference validation: intent network_pricing networks should exist in root x402 config
154
+ if (manifest.x402 && typeof manifest.x402 === "object" && manifest.intents && Array.isArray(manifest.intents)) {
155
+ const rootNetworks = new Set();
156
+ if (Array.isArray(manifest.x402.networks)) {
157
+ manifest.x402.networks.forEach((entry) => {
158
+ if (entry && typeof entry.network === "string") {
159
+ rootNetworks.add(entry.network);
160
+ }
161
+ });
162
+ } else if (typeof manifest.x402.network === "string") {
163
+ rootNetworks.add(manifest.x402.network);
164
+ }
165
+
166
+ if (rootNetworks.size > 0) {
167
+ manifest.intents.forEach((intent, i) => {
168
+ if (intent && intent.x402 && Array.isArray(intent.x402.network_pricing)) {
169
+ intent.x402.network_pricing.forEach((entry, j) => {
170
+ if (entry && typeof entry.network === "string" && !rootNetworks.has(entry.network)) {
171
+ warnings.push(
172
+ `intents[${i}].x402.network_pricing[${j}]: network "${entry.network}" is not declared in the root x402 configuration. Agents will have no settlement details for this network.`
173
+ );
174
+ }
175
+ });
176
+ }
177
+ });
178
+ }
179
+ }
180
+
181
+ // Check for unknown top-level fields
182
+ const knownFields = [
183
+ "version",
184
+ "origin",
185
+ "payout_address",
186
+ "display_name",
187
+ "description",
188
+ "extensions",
189
+ "identity",
190
+ "intents",
191
+ "bounty",
192
+ "incentive",
193
+ "x402",
194
+ ];
195
+ for (const key of Object.keys(manifest)) {
196
+ if (!knownFields.includes(key) && !key.startsWith("x-") && !key.startsWith("x_")) {
197
+ warnings.push(`Unknown top-level field: "${key}"`);
198
+ }
199
+ }
200
+
201
+ return {
202
+ valid: errors.length === 0,
203
+ errors,
204
+ warnings,
205
+ tier: determineTier(manifest),
206
+ };
207
+ }
208
+
209
+ function validateIdentity(identity, errors, warnings) {
210
+ if (typeof identity !== "object" || identity === null) {
211
+ errors.push('"identity" must be an object');
212
+ return;
213
+ }
214
+
215
+ if (identity.did !== undefined) {
216
+ if (typeof identity.did !== "string") {
217
+ errors.push('"identity.did" must be a string');
218
+ } else if (!identity.did.startsWith("did:")) {
219
+ errors.push(
220
+ `Invalid DID format: "${identity.did}". Must start with "did:"`
221
+ );
222
+ }
223
+ }
224
+
225
+ if (identity.public_key !== undefined) {
226
+ if (typeof identity.public_key !== "string") {
227
+ errors.push('"identity.public_key" must be a string');
228
+ }
229
+ }
230
+ }
231
+
232
+ function validateIntent(intent, index, nameSet, errors, warnings) {
233
+ const prefix = `intents[${index}]`;
234
+
235
+ if (typeof intent !== "object" || intent === null) {
236
+ errors.push(`${prefix}: must be an object`);
237
+ return;
238
+ }
239
+
240
+ // name
241
+ if (!intent.name) {
242
+ errors.push(`${prefix}: missing required field "name"`);
243
+ } else if (typeof intent.name !== "string") {
244
+ errors.push(`${prefix}: "name" must be a string`);
245
+ } else {
246
+ if (!INTENT_NAME_PATTERN.test(intent.name)) {
247
+ errors.push(
248
+ `${prefix}: intent name "${intent.name}" must be snake_case (lowercase letters, digits, underscores, starting with a letter)`
249
+ );
250
+ }
251
+ if (intent.name.length > 64) {
252
+ errors.push(
253
+ `${prefix}: intent name "${intent.name}" exceeds 64 character limit`
254
+ );
255
+ }
256
+ if (nameSet.has(intent.name)) {
257
+ errors.push(`${prefix}: duplicate intent name "${intent.name}"`);
258
+ }
259
+ nameSet.add(intent.name);
260
+ }
261
+
262
+ // description
263
+ if (!intent.description) {
264
+ errors.push(`${prefix}: missing required field "description"`);
265
+ } else if (typeof intent.description !== "string") {
266
+ errors.push(`${prefix}: "description" must be a string`);
267
+ } else {
268
+ if (intent.description.length < 10) {
269
+ warnings.push(
270
+ `${prefix}: description is very short (${intent.description.length} chars). Longer descriptions improve routing accuracy.`
271
+ );
272
+ }
273
+ if (intent.description.length > 500) {
274
+ warnings.push(
275
+ `${prefix}: description is ${intent.description.length} chars (recommended max: 500)`
276
+ );
277
+ }
278
+ }
279
+
280
+ // endpoint (optional)
281
+ const VALID_METHODS = ["GET", "POST", "PUT", "DELETE"];
282
+ if (intent.endpoint !== undefined) {
283
+ if (typeof intent.endpoint !== "string") {
284
+ errors.push(`${prefix}: "endpoint" must be a string`);
285
+ } else if (intent.endpoint.length === 0) {
286
+ errors.push(`${prefix}: "endpoint" must not be empty`);
287
+ }
288
+ }
289
+
290
+ // method (optional, but required when endpoint is present)
291
+ if (intent.method !== undefined) {
292
+ if (typeof intent.method !== "string") {
293
+ errors.push(`${prefix}: "method" must be a string`);
294
+ } else if (!VALID_METHODS.includes(intent.method)) {
295
+ errors.push(
296
+ `${prefix}: invalid method "${intent.method}". Must be one of: ${VALID_METHODS.join(", ")}`
297
+ );
298
+ }
299
+ if (intent.endpoint === undefined) {
300
+ warnings.push(
301
+ `${prefix}: "method" is set but "endpoint" is missing. Method is only used with direct API intents.`
302
+ );
303
+ }
304
+ } else if (intent.endpoint !== undefined) {
305
+ errors.push(
306
+ `${prefix}: "endpoint" is set but "method" is missing. The public schema requires "method" whenever "endpoint" is declared.`
307
+ );
308
+ }
309
+
310
+ if (intent.extensions !== undefined) {
311
+ if (typeof intent.extensions !== "object" || intent.extensions === null || Array.isArray(intent.extensions)) {
312
+ errors.push(`${prefix}: "extensions" must be an object`);
313
+ }
314
+ }
315
+
316
+ // parameters (optional)
317
+ if (intent.parameters !== undefined) {
318
+ if (typeof intent.parameters !== "object" || intent.parameters === null) {
319
+ errors.push(`${prefix}: "parameters" must be an object`);
320
+ } else {
321
+ for (const [paramName, param] of Object.entries(intent.parameters)) {
322
+ validateParameter(param, `${prefix}.parameters.${paramName}`, errors, warnings);
323
+ }
324
+ }
325
+ }
326
+
327
+ // returns (optional)
328
+ if (intent.returns !== undefined) {
329
+ if (typeof intent.returns !== "object" || intent.returns === null) {
330
+ errors.push(`${prefix}: "returns" must be an object`);
331
+ }
332
+ }
333
+
334
+ // price (optional)
335
+ if (intent.price !== undefined) {
336
+ validatePrice(intent.price, `${prefix}.price`, errors, warnings);
337
+ }
338
+
339
+ // bounty (optional, intent-level)
340
+ if (intent.bounty !== undefined) {
341
+ validateBounty(intent.bounty, `${prefix}.bounty`, errors, warnings);
342
+ }
343
+
344
+ // incentive (optional, intent-level)
345
+ if (intent.incentive !== undefined) {
346
+ validateIncentive(intent.incentive, `${prefix}.incentive`, errors, warnings);
347
+ }
348
+
349
+ // x402 (optional, intent-level)
350
+ if (intent.x402 !== undefined) {
351
+ validateX402Intent(intent.x402, `${prefix}.x402`, errors, warnings);
352
+ }
353
+
354
+ const knownFields = [
355
+ "name",
356
+ "description",
357
+ "extensions",
358
+ "endpoint",
359
+ "method",
360
+ "parameters",
361
+ "returns",
362
+ "price",
363
+ "bounty",
364
+ "incentive",
365
+ "x402",
366
+ ];
367
+ for (const key of Object.keys(intent)) {
368
+ if (!knownFields.includes(key) && !key.startsWith("x-") && !key.startsWith("x_")) {
369
+ warnings.push(`${prefix}: unknown field "${key}"`);
370
+ }
371
+ }
372
+ }
373
+
374
+ function validatePrice(price, path, errors, warnings) {
375
+ if (typeof price !== "object" || price === null) {
376
+ errors.push(`${path}: must be an object`);
377
+ return;
378
+ }
379
+
380
+ if (price.amount === undefined) {
381
+ errors.push(`${path}: missing required field "amount"`);
382
+ } else if (typeof price.amount !== "number" || price.amount < 0) {
383
+ errors.push(`${path}: "amount" must be a non-negative number`);
384
+ }
385
+
386
+ const VALID_PRICE_CURRENCIES = ["USD", "USDC"];
387
+ if (!price.currency) {
388
+ errors.push(`${path}: missing required field "currency"`);
389
+ } else if (!VALID_PRICE_CURRENCIES.includes(price.currency)) {
390
+ errors.push(
391
+ `${path}: invalid currency "${price.currency}". Must be one of: ${VALID_PRICE_CURRENCIES.join(", ")}`
392
+ );
393
+ }
394
+
395
+ const VALID_PRICE_MODELS = ["per_call", "per_unit", "flat"];
396
+ if (price.model !== undefined) {
397
+ if (!VALID_PRICE_MODELS.includes(price.model)) {
398
+ errors.push(
399
+ `${path}: invalid model "${price.model}". Must be one of: ${VALID_PRICE_MODELS.join(", ")}`
400
+ );
401
+ }
402
+ if (price.model === "per_unit" && !price.unit_param) {
403
+ errors.push(
404
+ `${path}: model is "per_unit" but "unit_param" is not set. Specify which parameter determines the unit count.`
405
+ );
406
+ }
407
+ }
408
+
409
+ if (price.unit_param !== undefined && typeof price.unit_param !== "string") {
410
+ errors.push(`${path}: "unit_param" must be a string`);
411
+ }
412
+
413
+ if (price.free_tier !== undefined) {
414
+ if (typeof price.free_tier !== "number" || price.free_tier < 0 || !Number.isInteger(price.free_tier)) {
415
+ errors.push(`${path}: "free_tier" must be a non-negative integer`);
416
+ }
417
+ }
418
+
419
+ if (price.network !== undefined) {
420
+ if (typeof price.network === "string") {
421
+ // valid — single network
422
+ } else if (Array.isArray(price.network)) {
423
+ if (price.network.length === 0) {
424
+ errors.push(`${path}: "network" array must not be empty`);
425
+ } else if (!price.network.every((n) => typeof n === "string")) {
426
+ errors.push(`${path}: "network" array must contain only strings`);
427
+ } else {
428
+ const seen = new Set();
429
+ for (const n of price.network) {
430
+ if (seen.has(n)) {
431
+ warnings.push(`${path}: duplicate network "${n}" in network array`);
432
+ }
433
+ seen.add(n);
434
+ }
435
+ }
436
+ } else {
437
+ errors.push(`${path}: "network" must be a string or array of strings`);
438
+ }
439
+
440
+ if (price.currency === "USD") {
441
+ warnings.push(
442
+ `${path}: "network" is set but currency is "USD". Network is typically used with on-chain currencies like "USDC".`
443
+ );
444
+ }
445
+ }
446
+ }
447
+
448
+ function validateX402Root(x402, path, errors, warnings) {
449
+ if (typeof x402 !== "object" || x402 === null) {
450
+ errors.push(`${path}: must be an object`);
451
+ return;
452
+ }
453
+
454
+ if (x402.supported === undefined) {
455
+ errors.push(`${path}: missing required field "supported"`);
456
+ } else if (typeof x402.supported !== "boolean") {
457
+ errors.push(`${path}: "supported" must be a boolean`);
458
+ }
459
+
460
+ if (x402.recipient !== undefined && typeof x402.recipient !== "string") {
461
+ errors.push(`${path}: "recipient" must be a string`);
462
+ }
463
+
464
+ if (x402.networks !== undefined) {
465
+ // Multi-network mode
466
+ if (!Array.isArray(x402.networks)) {
467
+ errors.push(`${path}: "networks" must be an array`);
468
+ } else if (x402.networks.length === 0) {
469
+ errors.push(`${path}: "networks" array must not be empty`);
470
+ } else {
471
+ const seenNetworks = new Set();
472
+ x402.networks.forEach((entry, i) => {
473
+ const entryPath = `${path}.networks[${i}]`;
474
+ if (typeof entry !== "object" || entry === null) {
475
+ errors.push(`${entryPath}: must be an object`);
476
+ return;
477
+ }
478
+ if (!entry.network || typeof entry.network !== "string") {
479
+ errors.push(`${entryPath}: missing required field "network" (string)`);
480
+ } else {
481
+ if (seenNetworks.has(entry.network)) {
482
+ warnings.push(`${entryPath}: duplicate network "${entry.network}" in networks array`);
483
+ }
484
+ seenNetworks.add(entry.network);
485
+ }
486
+ if (!entry.asset || typeof entry.asset !== "string") {
487
+ errors.push(`${entryPath}: missing required field "asset" (string)`);
488
+ }
489
+ if (entry.contract !== undefined && typeof entry.contract !== "string") {
490
+ errors.push(`${entryPath}: "contract" must be a string`);
491
+ }
492
+ if (entry.facilitator !== undefined && typeof entry.facilitator !== "string") {
493
+ errors.push(`${entryPath}: "facilitator" must be a string`);
494
+ }
495
+ });
496
+ }
497
+ } else {
498
+ // Single-network (flat) mode
499
+ if (x402.network !== undefined && typeof x402.network !== "string") {
500
+ errors.push(`${path}: "network" must be a string`);
501
+ }
502
+ if (x402.asset !== undefined && typeof x402.asset !== "string") {
503
+ errors.push(`${path}: "asset" must be a string`);
504
+ }
505
+ if (x402.contract !== undefined && typeof x402.contract !== "string") {
506
+ errors.push(`${path}: "contract" must be a string`);
507
+ }
508
+ if (x402.facilitator !== undefined && typeof x402.facilitator !== "string") {
509
+ errors.push(`${path}: "facilitator" must be a string`);
510
+ }
511
+
512
+ if (x402.supported === true && !x402.network && !x402.asset) {
513
+ warnings.push(
514
+ `${path}: x402 is supported but no network or asset is declared. Agents won't know how to pay.`
515
+ );
516
+ }
517
+ }
518
+ }
519
+
520
+ function validateX402Intent(x402, path, errors, warnings) {
521
+ if (typeof x402 !== "object" || x402 === null) {
522
+ errors.push(`${path}: must be an object`);
523
+ return;
524
+ }
525
+
526
+ if (x402.supported !== undefined && typeof x402.supported !== "boolean") {
527
+ errors.push(`${path}: "supported" must be a boolean`);
528
+ }
529
+
530
+ if (x402.direct_price !== undefined) {
531
+ if (typeof x402.direct_price !== "number" || x402.direct_price < 0) {
532
+ errors.push(`${path}: "direct_price" must be a non-negative number`);
533
+ }
534
+ }
535
+
536
+ if (x402.ticket_price !== undefined) {
537
+ if (typeof x402.ticket_price !== "number" || x402.ticket_price < 0) {
538
+ errors.push(`${path}: "ticket_price" must be a non-negative number`);
539
+ }
540
+ }
541
+
542
+ if (x402.description !== undefined && typeof x402.description !== "string") {
543
+ errors.push(`${path}: "description" must be a string`);
544
+ }
545
+
546
+ if (
547
+ typeof x402.ticket_price === "number" &&
548
+ typeof x402.direct_price === "number" &&
549
+ x402.ticket_price > x402.direct_price
550
+ ) {
551
+ warnings.push(
552
+ `${path}: "ticket_price" (${x402.ticket_price}) is greater than "direct_price" (${x402.direct_price}). Session tickets are typically discounted.`
553
+ );
554
+ }
555
+
556
+ if (x402.network_pricing !== undefined) {
557
+ if (!Array.isArray(x402.network_pricing)) {
558
+ errors.push(`${path}: "network_pricing" must be an array`);
559
+ } else if (x402.network_pricing.length === 0) {
560
+ errors.push(`${path}: "network_pricing" array must not be empty`);
561
+ } else {
562
+ const seenPricingNetworks = new Set();
563
+ x402.network_pricing.forEach((entry, i) => {
564
+ const entryPath = `${path}.network_pricing[${i}]`;
565
+ if (typeof entry !== "object" || entry === null) {
566
+ errors.push(`${entryPath}: must be an object`);
567
+ return;
568
+ }
569
+ if (!entry.network || typeof entry.network !== "string") {
570
+ errors.push(`${entryPath}: missing required field "network" (string)`);
571
+ } else {
572
+ if (seenPricingNetworks.has(entry.network)) {
573
+ warnings.push(`${entryPath}: duplicate network "${entry.network}" in network_pricing array`);
574
+ }
575
+ seenPricingNetworks.add(entry.network);
576
+ }
577
+ if (entry.direct_price !== undefined) {
578
+ if (typeof entry.direct_price !== "number" || entry.direct_price < 0) {
579
+ errors.push(`${entryPath}: "direct_price" must be a non-negative number`);
580
+ }
581
+ }
582
+ if (entry.ticket_price !== undefined) {
583
+ if (typeof entry.ticket_price !== "number" || entry.ticket_price < 0) {
584
+ errors.push(`${entryPath}: "ticket_price" must be a non-negative number`);
585
+ }
586
+ }
587
+ if (
588
+ typeof entry.ticket_price === "number" &&
589
+ typeof entry.direct_price === "number" &&
590
+ entry.ticket_price > entry.direct_price
591
+ ) {
592
+ warnings.push(
593
+ `${entryPath}: "ticket_price" (${entry.ticket_price}) is greater than "direct_price" (${entry.direct_price}). Session tickets are typically discounted.`
594
+ );
595
+ }
596
+ });
597
+ }
598
+ }
599
+ }
600
+
601
+ function validateParameter(param, path, errors, warnings) {
602
+ if (typeof param !== "object" || param === null) {
603
+ errors.push(`${path}: must be an object`);
604
+ return;
605
+ }
606
+
607
+ if (!param.type) {
608
+ errors.push(`${path}: missing required field "type"`);
609
+ } else if (!VALID_PARAM_TYPES.includes(param.type)) {
610
+ errors.push(
611
+ `${path}: invalid type "${param.type}". Must be one of: ${VALID_PARAM_TYPES.join(", ")}`
612
+ );
613
+ }
614
+
615
+ if (param.required !== undefined && typeof param.required !== "boolean") {
616
+ errors.push(`${path}: "required" must be a boolean`);
617
+ }
618
+
619
+ if (param.enum !== undefined) {
620
+ if (!Array.isArray(param.enum) || param.enum.length === 0) {
621
+ errors.push(`${path}: "enum" must be a non-empty array`);
622
+ }
623
+ }
624
+ }
625
+
626
+ function validateBounty(bounty, path, errors, warnings) {
627
+ if (typeof bounty !== "object" || bounty === null) {
628
+ errors.push(`${path}: must be an object`);
629
+ return;
630
+ }
631
+
632
+ if (!bounty.type) {
633
+ errors.push(`${path}: missing required field "type"`);
634
+ } else if (!VALID_BOUNTY_TYPES.includes(bounty.type)) {
635
+ errors.push(
636
+ `${path}: invalid bounty type "${bounty.type}". Must be one of: ${VALID_BOUNTY_TYPES.join(", ")}`
637
+ );
638
+ }
639
+
640
+ if (bounty.rate === undefined) {
641
+ errors.push(`${path}: missing required field "rate"`);
642
+ } else if (typeof bounty.rate !== "number" || bounty.rate < 0) {
643
+ errors.push(`${path}: "rate" must be a non-negative number`);
644
+ }
645
+
646
+ if (!bounty.currency) {
647
+ errors.push(`${path}: missing required field "currency"`);
648
+ } else if (!VALID_CURRENCIES.includes(bounty.currency)) {
649
+ errors.push(
650
+ `${path}: invalid currency "${bounty.currency}". Must be one of: ${VALID_CURRENCIES.join(", ")}`
651
+ );
652
+ }
653
+
654
+ if (bounty.splits !== undefined) {
655
+ validateSplits(bounty.splits, `${path}.splits`, errors, warnings);
656
+ }
657
+ }
658
+
659
+ function validateSplits(splits, path, errors, warnings) {
660
+ if (typeof splits !== "object" || splits === null) {
661
+ errors.push(`${path}: must be an object`);
662
+ return;
663
+ }
664
+
665
+ const validKeys = ["orchestrator", "platform", "referrer"];
666
+ let sum = 0;
667
+
668
+ for (const [key, value] of Object.entries(splits)) {
669
+ if (!validKeys.includes(key)) {
670
+ if (!key.startsWith("x-") && !key.startsWith("x_")) {
671
+ warnings.push(`${path}: unknown split key "${key}"`);
672
+ }
673
+ continue;
674
+ }
675
+ if (typeof value !== "number" || value < 0 || value > 1) {
676
+ errors.push(`${path}.${key}: must be a number between 0 and 1`);
677
+ } else {
678
+ sum += value;
679
+ }
680
+ }
681
+
682
+ if (Math.abs(sum - 1.0) > 0.001) {
683
+ errors.push(
684
+ `${path}: splits must sum to 1.0 (currently ${sum.toFixed(3)})`
685
+ );
686
+ }
687
+ }
688
+
689
+ function validateIncentive(incentive, path, errors, warnings) {
690
+ if (typeof incentive !== "object" || incentive === null) {
691
+ errors.push(`${path}: must be an object`);
692
+ return;
693
+ }
694
+
695
+ if (!incentive.type) {
696
+ errors.push(`${path}: missing required field "type"`);
697
+ } else if (!VALID_BOUNTY_TYPES.includes(incentive.type)) {
698
+ errors.push(
699
+ `${path}: invalid incentive type "${incentive.type}". Must be one of: ${VALID_BOUNTY_TYPES.join(", ")}`
700
+ );
701
+ }
702
+
703
+ if (incentive.rate === undefined) {
704
+ errors.push(`${path}: missing required field "rate"`);
705
+ } else if (typeof incentive.rate !== "number" || incentive.rate < 0) {
706
+ errors.push(`${path}: "rate" must be a non-negative number`);
707
+ }
708
+
709
+ if (!incentive.currency) {
710
+ errors.push(`${path}: missing required field "currency"`);
711
+ } else if (!VALID_CURRENCIES.includes(incentive.currency)) {
712
+ errors.push(
713
+ `${path}: invalid currency "${incentive.currency}". Must be one of: ${VALID_CURRENCIES.join(", ")}`
714
+ );
715
+ }
716
+ }
717
+
718
+ function determineTier(manifest) {
719
+ if (manifest.identity && (manifest.identity.did || manifest.identity.public_key)) {
720
+ return 3;
721
+ }
722
+ if (manifest.intents && manifest.intents.length > 0) {
723
+ return 2;
724
+ }
725
+ return 1;
726
+ }
727
+
728
+ // --- Fetching ---
729
+
730
+ function fetchUrl(url) {
731
+ return new Promise((resolve, reject) => {
732
+ const client = url.startsWith("https") ? https : http;
733
+ client
734
+ .get(url, (res) => {
735
+ if (res.statusCode === 301 || res.statusCode === 302) {
736
+ const location = res.headers.location;
737
+ if (location) {
738
+ fetchUrl(location).then(resolve).catch(reject);
739
+ return;
740
+ }
741
+ }
742
+ if (res.statusCode !== 200) {
743
+ reject(new Error(`HTTP ${res.statusCode}`));
744
+ return;
745
+ }
746
+ let data = "";
747
+ res.on("data", (chunk) => (data += chunk));
748
+ res.on("end", () => resolve(data));
749
+ })
750
+ .on("error", reject);
751
+ });
752
+ }
753
+
754
+ async function loadManifest(input) {
755
+ // URL
756
+ if (input.startsWith("http://") || input.startsWith("https://")) {
757
+ const body = await fetchUrl(input);
758
+ return { manifest: JSON.parse(body), sourceUrl: input };
759
+ }
760
+
761
+ // Local file
762
+ const resolved = path.resolve(input);
763
+ const content = fs.readFileSync(resolved, "utf-8");
764
+ return { manifest: JSON.parse(content), sourceUrl: null };
765
+ }
766
+
767
+ async function loadFromStdin() {
768
+ return new Promise((resolve) => {
769
+ let data = "";
770
+ process.stdin.setEncoding("utf-8");
771
+ process.stdin.on("data", (chunk) => (data += chunk));
772
+ process.stdin.on("end", () => {
773
+ resolve({ manifest: JSON.parse(data), sourceUrl: null });
774
+ });
775
+ });
776
+ }
777
+
778
+ // --- Output ---
779
+
780
+ function printResult(result) {
781
+ const tierLabel = `Tier ${result.tier}`;
782
+
783
+ if (result.valid) {
784
+ console.log(`\n ✓ Valid agent.json (${tierLabel})\n`);
785
+ } else {
786
+ console.log(`\n ✗ Invalid agent.json\n`);
787
+ }
788
+
789
+ if (result.errors.length > 0) {
790
+ console.log(" Errors:");
791
+ result.errors.forEach((e) => console.log(` ✗ ${e}`));
792
+ console.log();
793
+ }
794
+
795
+ if (result.warnings.length > 0) {
796
+ console.log(" Warnings:");
797
+ result.warnings.forEach((w) => console.log(` ⚠ ${w}`));
798
+ console.log();
799
+ }
800
+
801
+ if (result.valid && result.warnings.length === 0) {
802
+ console.log(" No issues found.\n");
803
+ }
804
+ }
805
+
806
+ // --- CLI ---
807
+
808
+ async function main() {
809
+ const args = process.argv.slice(2);
810
+
811
+ if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
812
+ console.log(`
813
+ agent.json validator
814
+
815
+ Usage:
816
+ npx agent-json-validate <url-or-path>
817
+ npx agent-json-validate https://example.com/.well-known/agent.json
818
+ npx agent-json-validate ./agent.json
819
+ cat agent.json | npx agent-json-validate --stdin
820
+
821
+ Options:
822
+ --stdin Read manifest from stdin
823
+ --json Output results as JSON
824
+ --help Show this help
825
+ `);
826
+ process.exit(0);
827
+ }
828
+
829
+ const jsonOutput = args.includes("--json");
830
+ const useStdin = args.includes("--stdin");
831
+ const input = args.find((a) => !a.startsWith("--"));
832
+
833
+ try {
834
+ const { manifest, sourceUrl } = useStdin
835
+ ? await loadFromStdin()
836
+ : await loadManifest(input);
837
+
838
+ const result = validate(manifest, sourceUrl);
839
+
840
+ if (jsonOutput) {
841
+ console.log(JSON.stringify(result, null, 2));
842
+ } else {
843
+ printResult(result);
844
+ }
845
+
846
+ process.exit(result.valid ? 0 : 1);
847
+ } catch (err) {
848
+ if (err instanceof SyntaxError) {
849
+ console.error("\n ✗ Invalid JSON:", err.message, "\n");
850
+ } else {
851
+ console.error("\n ✗ Error:", err.message, "\n");
852
+ }
853
+ process.exit(1);
854
+ }
855
+ }
856
+
857
+ // Export for programmatic use
858
+ module.exports = { validate };
859
+
860
+ // Run CLI if invoked directly
861
+ if (require.main === module) {
862
+ main();
863
+ }