ai-catalog 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/LICENSE +202 -0
- package/README.md +110 -0
- package/dist/cli/index.js +605 -0
- package/dist/index.cjs +519 -0
- package/dist/index.d.cts +303 -0
- package/dist/index.d.ts +303 -0
- package/dist/index.js +472 -0
- package/package.json +57 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { readFile, writeFile, mkdir } from "fs/promises";
|
|
5
|
+
import { existsSync } from "fs";
|
|
6
|
+
import { dirname } from "path";
|
|
7
|
+
|
|
8
|
+
// src/types.ts
|
|
9
|
+
var SPEC_VERSION = "1.0";
|
|
10
|
+
var WELL_KNOWN_PATH = "/.well-known/ai-catalog.json";
|
|
11
|
+
var CATALOG_MEDIA_TYPE = "application/ai-catalog+json";
|
|
12
|
+
var LINK_REL = "ai-catalog";
|
|
13
|
+
var DEFAULT_MAX_DEPTH = 4;
|
|
14
|
+
var KnownType = {
|
|
15
|
+
/** A nested AI Catalog. */
|
|
16
|
+
AiCatalog: "application/ai-catalog+json",
|
|
17
|
+
/** Reserved generic Agent Card format. */
|
|
18
|
+
AgentCard: "application/agent-card+json",
|
|
19
|
+
/** An A2A Agent Card. */
|
|
20
|
+
A2aAgentCard: "application/a2a-agent-card+json",
|
|
21
|
+
/** An MCP Server Card. */
|
|
22
|
+
McpServerCard: "application/mcp-server-card+json",
|
|
23
|
+
/** Agent Skill metadata JSON. */
|
|
24
|
+
AgentSkillsJson: "application/agent-skills+json",
|
|
25
|
+
/** Agent Skill in Markdown. */
|
|
26
|
+
AgentSkillsMd: "application/agent-skills+md",
|
|
27
|
+
/** Agent Skill bundle (ZIP). */
|
|
28
|
+
AgentSkillsZip: "application/agent-skills+zip",
|
|
29
|
+
/** Agent Skill bundle (gzipped tarball). */
|
|
30
|
+
AgentSkillsGzip: "application/agent-skills+gzip"
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// src/urn.ts
|
|
34
|
+
function parseAirUrn(identifier) {
|
|
35
|
+
if (typeof identifier !== "string") return null;
|
|
36
|
+
const parts = identifier.split(":");
|
|
37
|
+
if (parts.length < 5) return null;
|
|
38
|
+
if (parts[0].toLowerCase() !== "urn" || parts[1].toLowerCase() !== "air") {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const publisher = parts[2];
|
|
42
|
+
const name = parts[parts.length - 1];
|
|
43
|
+
const namespace = parts.slice(3, parts.length - 1).join(":");
|
|
44
|
+
if (!publisher || !namespace || !name) return null;
|
|
45
|
+
return { publisher, namespace, name };
|
|
46
|
+
}
|
|
47
|
+
function isAirUrn(identifier) {
|
|
48
|
+
return parseAirUrn(identifier) !== null;
|
|
49
|
+
}
|
|
50
|
+
function identifierTail(identifier) {
|
|
51
|
+
const m = identifier.match(/[^:/]+$/);
|
|
52
|
+
return m ? m[0] : identifier;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// src/validate.ts
|
|
56
|
+
var KNOWN_TYPES = new Set(Object.values(KnownType));
|
|
57
|
+
function isPlainObject(v) {
|
|
58
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
59
|
+
}
|
|
60
|
+
function isRfc3339(s) {
|
|
61
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(s)) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
return !Number.isNaN(Date.parse(s));
|
|
65
|
+
}
|
|
66
|
+
function validateCatalog(doc, opts = {}) {
|
|
67
|
+
const errors = [];
|
|
68
|
+
const warnings = [];
|
|
69
|
+
const err = (path, message) => errors.push({ path, message, severity: "error" });
|
|
70
|
+
const warn = (path, message) => warnings.push({ path, message, severity: "warning" });
|
|
71
|
+
if (!isPlainObject(doc)) {
|
|
72
|
+
err("", "catalog must be a JSON object");
|
|
73
|
+
return { valid: false, errors, warnings };
|
|
74
|
+
}
|
|
75
|
+
if (typeof doc.specVersion !== "string") {
|
|
76
|
+
err("/specVersion", "required string member is missing");
|
|
77
|
+
} else if (!/^\d+\.\d+$/.test(doc.specVersion)) {
|
|
78
|
+
warn("/specVersion", `should be "Major.Minor" (got "${doc.specVersion}")`);
|
|
79
|
+
}
|
|
80
|
+
if (doc.host !== void 0) validateHost(doc.host, "/host", err, warn);
|
|
81
|
+
if (doc.metadata !== void 0 && !isPlainObject(doc.metadata)) {
|
|
82
|
+
err("/metadata", "metadata must be an object");
|
|
83
|
+
}
|
|
84
|
+
if (!Array.isArray(doc.entries)) {
|
|
85
|
+
err("/entries", "required array member is missing");
|
|
86
|
+
} else {
|
|
87
|
+
const seen = /* @__PURE__ */ new Map();
|
|
88
|
+
doc.entries.forEach((entry, i) => {
|
|
89
|
+
validateEntry(entry, `/entries/${i}`, err, warn);
|
|
90
|
+
if (isPlainObject(entry) && typeof entry.identifier === "string") {
|
|
91
|
+
const versions = seen.get(entry.identifier) ?? /* @__PURE__ */ new Set();
|
|
92
|
+
const v = typeof entry.version === "string" ? entry.version : void 0;
|
|
93
|
+
if (versions.has(v)) {
|
|
94
|
+
err(
|
|
95
|
+
`/entries/${i}/identifier`,
|
|
96
|
+
v === void 0 ? `duplicate identifier "${entry.identifier}" (identifier alone must be unique when no version is set)` : `duplicate identifier+version "${entry.identifier}"@"${v}"`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
versions.add(v);
|
|
100
|
+
seen.set(entry.identifier, versions);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
const allErrors = opts.strict ? [...errors, ...warnings] : errors;
|
|
105
|
+
return { valid: allErrors.length === 0, errors, warnings };
|
|
106
|
+
}
|
|
107
|
+
function validateHost(host, path, err, warn) {
|
|
108
|
+
if (!isPlainObject(host)) {
|
|
109
|
+
err(path, "host must be an object");
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (typeof host.displayName !== "string" || host.displayName.length === 0) {
|
|
113
|
+
err(`${path}/displayName`, "host.displayName is required and must be a non-empty string");
|
|
114
|
+
}
|
|
115
|
+
for (const k of ["identifier", "documentationUrl", "logoUrl"]) {
|
|
116
|
+
if (host[k] !== void 0 && typeof host[k] !== "string") {
|
|
117
|
+
err(`${path}/${k}`, `${k} must be a string`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
function validateEntry(entry, path, err, warn) {
|
|
122
|
+
if (!isPlainObject(entry)) {
|
|
123
|
+
err(path, "entry must be an object");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (typeof entry.identifier !== "string" || entry.identifier.length === 0) {
|
|
127
|
+
err(`${path}/identifier`, "required string member is missing");
|
|
128
|
+
} else if (!isAirUrn(entry.identifier)) {
|
|
129
|
+
warn(
|
|
130
|
+
`${path}/identifier`,
|
|
131
|
+
`should use urn:air:{publisher}:{namespace}:{name} for open/federated use (got "${entry.identifier}")`
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (typeof entry.type !== "string" || entry.type.length === 0) {
|
|
135
|
+
if (typeof entry.mediaType === "string") {
|
|
136
|
+
err(`${path}/type`, "required `type` is missing \u2014 `mediaType` was renamed to `type` (ADR-0014)");
|
|
137
|
+
} else {
|
|
138
|
+
err(`${path}/type`, "required string member is missing");
|
|
139
|
+
}
|
|
140
|
+
} else if (!KNOWN_TYPES.has(entry.type)) {
|
|
141
|
+
warn(`${path}/type`, `"${entry.type}" is not a recognized known type`);
|
|
142
|
+
}
|
|
143
|
+
const hasUrl = entry.url !== void 0;
|
|
144
|
+
const hasData = entry.data !== void 0;
|
|
145
|
+
if (hasUrl && hasData) {
|
|
146
|
+
err(path, "entry must contain exactly one of `url` or `data`, not both");
|
|
147
|
+
} else if (!hasUrl && !hasData) {
|
|
148
|
+
err(path, "entry must contain exactly one of `url` or `data`");
|
|
149
|
+
}
|
|
150
|
+
if (hasUrl && typeof entry.url !== "string") {
|
|
151
|
+
err(`${path}/url`, "url must be a string");
|
|
152
|
+
}
|
|
153
|
+
if (entry.displayName !== void 0 && typeof entry.displayName !== "string") {
|
|
154
|
+
err(`${path}/displayName`, "displayName must be a string");
|
|
155
|
+
}
|
|
156
|
+
if (entry.description !== void 0 && typeof entry.description !== "string") {
|
|
157
|
+
err(`${path}/description`, "description must be a string");
|
|
158
|
+
}
|
|
159
|
+
if (entry.version !== void 0 && typeof entry.version !== "string") {
|
|
160
|
+
err(`${path}/version`, "version must be a string");
|
|
161
|
+
}
|
|
162
|
+
if (entry.tags !== void 0) {
|
|
163
|
+
if (!Array.isArray(entry.tags) || entry.tags.some((t) => typeof t !== "string")) {
|
|
164
|
+
err(`${path}/tags`, "tags must be an array of strings");
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (entry.updatedAt !== void 0) {
|
|
168
|
+
if (typeof entry.updatedAt !== "string") {
|
|
169
|
+
err(`${path}/updatedAt`, "updatedAt must be a string");
|
|
170
|
+
} else if (!isRfc3339(entry.updatedAt)) {
|
|
171
|
+
warn(`${path}/updatedAt`, "updatedAt should be an RFC 3339 timestamp");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (entry.publisher !== void 0) {
|
|
175
|
+
const p = entry.publisher;
|
|
176
|
+
if (!isPlainObject(p)) {
|
|
177
|
+
err(`${path}/publisher`, "publisher must be an object");
|
|
178
|
+
} else {
|
|
179
|
+
if (typeof p.identifier !== "string" || p.identifier.length === 0) {
|
|
180
|
+
err(`${path}/publisher/identifier`, "publisher.identifier is required");
|
|
181
|
+
}
|
|
182
|
+
if (typeof p.displayName !== "string" || p.displayName.length === 0) {
|
|
183
|
+
err(`${path}/publisher/displayName`, "publisher.displayName is required");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (entry.trustManifest !== void 0 && typeof entry.identifier === "string") {
|
|
188
|
+
const tm = entry.trustManifest;
|
|
189
|
+
const air = parseAirUrn(entry.identifier);
|
|
190
|
+
const idDomain = isPlainObject(tm) && isPlainObject(tm.identity) && typeof tm.identity.identifier === "string" ? tm.identity.identifier : void 0;
|
|
191
|
+
if (air && idDomain) {
|
|
192
|
+
const domainSegment = stripIdentityScheme(idDomain);
|
|
193
|
+
if (domainSegment && !domainMatches(air.publisher, domainSegment)) {
|
|
194
|
+
err(
|
|
195
|
+
`${path}/trustManifest/identity/identifier`,
|
|
196
|
+
`trust identity domain "${domainSegment}" must align with the entry publisher "${air.publisher}"`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
function stripIdentityScheme(id) {
|
|
203
|
+
if (id.startsWith("did:web:")) return id.slice("did:web:".length).split(":")[0];
|
|
204
|
+
if (id.startsWith("dns:")) return id.slice("dns:".length);
|
|
205
|
+
if (id.startsWith("https://")) {
|
|
206
|
+
try {
|
|
207
|
+
return new URL(id).hostname;
|
|
208
|
+
} catch {
|
|
209
|
+
return void 0;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
if (/^[a-z0-9.-]+\.[a-z]{2,}$/i.test(id)) return id;
|
|
213
|
+
return void 0;
|
|
214
|
+
}
|
|
215
|
+
function domainMatches(publisher, domain) {
|
|
216
|
+
const p = publisher.toLowerCase().replace(/\.$/, "");
|
|
217
|
+
const d = domain.toLowerCase().replace(/\.$/, "");
|
|
218
|
+
return p === d || d.endsWith(`.${p}`) || p.endsWith(`.${d}`);
|
|
219
|
+
}
|
|
220
|
+
function assertValidCatalog(doc, opts) {
|
|
221
|
+
const r = validateCatalog(doc, opts);
|
|
222
|
+
const fatal = opts?.strict ? [...r.errors, ...r.warnings] : r.errors;
|
|
223
|
+
if (fatal.length > 0) {
|
|
224
|
+
const lines = fatal.map((i) => ` ${i.path || "/"}: ${i.message}`).join("\n");
|
|
225
|
+
throw new Error(`Invalid ai-catalog.json:
|
|
226
|
+
${lines}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// src/builder.ts
|
|
231
|
+
var CatalogBuilder = class {
|
|
232
|
+
catalog;
|
|
233
|
+
constructor(host, specVersion = SPEC_VERSION) {
|
|
234
|
+
this.catalog = { specVersion, entries: [] };
|
|
235
|
+
if (host) this.catalog.host = host;
|
|
236
|
+
}
|
|
237
|
+
/** Set or replace the Host Info object. */
|
|
238
|
+
setHost(host) {
|
|
239
|
+
this.catalog.host = host;
|
|
240
|
+
return this;
|
|
241
|
+
}
|
|
242
|
+
/** Set a catalog-level metadata key. */
|
|
243
|
+
setMetadata(key, value) {
|
|
244
|
+
this.catalog.metadata ??= {};
|
|
245
|
+
this.catalog.metadata[key] = value;
|
|
246
|
+
return this;
|
|
247
|
+
}
|
|
248
|
+
/** Append a catalog entry. */
|
|
249
|
+
addEntry(entry) {
|
|
250
|
+
this.catalog.entries.push(entry);
|
|
251
|
+
return this;
|
|
252
|
+
}
|
|
253
|
+
/** Append several catalog entries. */
|
|
254
|
+
addEntries(entries) {
|
|
255
|
+
this.catalog.entries.push(...entries);
|
|
256
|
+
return this;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Add a nested-catalog entry (`type: application/ai-catalog+json`) that points
|
|
260
|
+
* at another catalog by URL.
|
|
261
|
+
*/
|
|
262
|
+
addNestedCatalog(identifier, url, extra) {
|
|
263
|
+
return this.addEntry({
|
|
264
|
+
identifier,
|
|
265
|
+
type: "application/ai-catalog+json",
|
|
266
|
+
url,
|
|
267
|
+
...extra
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/** The built catalog object (a fresh shallow copy each call). */
|
|
271
|
+
build() {
|
|
272
|
+
return { ...this.catalog, entries: [...this.catalog.entries] };
|
|
273
|
+
}
|
|
274
|
+
/** Validate the built catalog, throwing on any error. Returns the catalog. */
|
|
275
|
+
validateOrThrow(opts) {
|
|
276
|
+
const built = this.build();
|
|
277
|
+
assertValidCatalog(built, opts);
|
|
278
|
+
return built;
|
|
279
|
+
}
|
|
280
|
+
/** Serialize to a JSON string. */
|
|
281
|
+
toJSON(indent = 2) {
|
|
282
|
+
return JSON.stringify(this.build(), null, indent);
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// src/resolve.ts
|
|
287
|
+
function getFetch(opts) {
|
|
288
|
+
const f = opts?.fetch ?? globalThis.fetch;
|
|
289
|
+
if (!f) {
|
|
290
|
+
throw new Error("No fetch available. Pass options.fetch or run on Node 18+.");
|
|
291
|
+
}
|
|
292
|
+
return f;
|
|
293
|
+
}
|
|
294
|
+
async function withTimeout(fetchFn, url, init, timeoutMs) {
|
|
295
|
+
const ctrl = new AbortController();
|
|
296
|
+
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
297
|
+
try {
|
|
298
|
+
return await fetchFn(url, { ...init, signal: ctrl.signal });
|
|
299
|
+
} finally {
|
|
300
|
+
clearTimeout(t);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function parseLinkHeader(header) {
|
|
304
|
+
if (!header) return null;
|
|
305
|
+
for (const part of header.split(",")) {
|
|
306
|
+
const m = part.match(/<([^>]+)>\s*;\s*(.+)/);
|
|
307
|
+
if (!m) continue;
|
|
308
|
+
const target = m[1].trim();
|
|
309
|
+
const params = m[2];
|
|
310
|
+
const rel = params.match(/rel\s*=\s*"?([^";]+)"?/i)?.[1]?.trim();
|
|
311
|
+
if (rel && rel.split(/\s+/).includes(LINK_REL)) return target;
|
|
312
|
+
}
|
|
313
|
+
return null;
|
|
314
|
+
}
|
|
315
|
+
function parseHtmlLink(html) {
|
|
316
|
+
const linkTags = html.match(/<link\b[^>]*>/gi) ?? [];
|
|
317
|
+
for (const tag of linkTags) {
|
|
318
|
+
const rel = tag.match(/\brel\s*=\s*["']?([^"'>]+)["']?/i)?.[1];
|
|
319
|
+
if (!rel || !rel.split(/\s+/).includes(LINK_REL)) continue;
|
|
320
|
+
const href = tag.match(/\bhref\s*=\s*["']([^"']+)["']/i)?.[1];
|
|
321
|
+
if (href) return href;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
async function discover(siteUrl, opts = {}) {
|
|
326
|
+
const fetchFn = getFetch(opts);
|
|
327
|
+
const timeoutMs = opts.timeoutMs ?? 1e4;
|
|
328
|
+
const base = new URL(siteUrl);
|
|
329
|
+
const resp = await withTimeout(fetchFn, base.toString(), { redirect: "follow" }, timeoutMs);
|
|
330
|
+
const headerTarget = parseLinkHeader(resp.headers.get("link"));
|
|
331
|
+
if (headerTarget) {
|
|
332
|
+
const url = new URL(headerTarget, resp.url || base).toString();
|
|
333
|
+
return { url, via: "link-header", catalog: await fetchCatalog(url, fetchFn, timeoutMs) };
|
|
334
|
+
}
|
|
335
|
+
const contentType = resp.headers.get("content-type") ?? "";
|
|
336
|
+
if (contentType.includes("html")) {
|
|
337
|
+
const html = await resp.text();
|
|
338
|
+
const htmlTarget = parseHtmlLink(html);
|
|
339
|
+
if (htmlTarget) {
|
|
340
|
+
const url = new URL(htmlTarget, resp.url || base).toString();
|
|
341
|
+
return { url, via: "html-link", catalog: await fetchCatalog(url, fetchFn, timeoutMs) };
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (!opts.noWellKnownFallback) {
|
|
345
|
+
const url = new URL(WELL_KNOWN_PATH, base.origin).toString();
|
|
346
|
+
return { url, via: "well-known", catalog: await fetchCatalog(url, fetchFn, timeoutMs) };
|
|
347
|
+
}
|
|
348
|
+
throw new Error(`No ai-catalog discovered at ${siteUrl} (no Link header, no HTML <link>).`);
|
|
349
|
+
}
|
|
350
|
+
async function fetchCatalog(url, fetchFn = getFetch(), timeoutMs = 1e4) {
|
|
351
|
+
const resp = await withTimeout(
|
|
352
|
+
fetchFn,
|
|
353
|
+
url,
|
|
354
|
+
{ redirect: "follow", headers: { accept: `${CATALOG_MEDIA_TYPE}, application/json` } },
|
|
355
|
+
timeoutMs
|
|
356
|
+
);
|
|
357
|
+
if (!resp.ok) {
|
|
358
|
+
throw new Error(`Failed to fetch catalog ${url}: HTTP ${resp.status}`);
|
|
359
|
+
}
|
|
360
|
+
let doc;
|
|
361
|
+
try {
|
|
362
|
+
doc = await resp.json();
|
|
363
|
+
} catch {
|
|
364
|
+
throw new Error(`Catalog at ${url} is not valid JSON`);
|
|
365
|
+
}
|
|
366
|
+
const result = validateCatalog(doc);
|
|
367
|
+
if (!result.valid) {
|
|
368
|
+
const first = result.errors[0];
|
|
369
|
+
throw new Error(
|
|
370
|
+
`Catalog at ${url} is not a valid ai-catalog.json: ${first.path || "/"} ${first.message}`
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
return doc;
|
|
374
|
+
}
|
|
375
|
+
async function resolveCatalog(root, opts = {}) {
|
|
376
|
+
const fetchFn = getFetch(opts);
|
|
377
|
+
const timeoutMs = opts.timeoutMs ?? 1e4;
|
|
378
|
+
const maxDepth = opts.maxDepth ?? DEFAULT_MAX_DEPTH;
|
|
379
|
+
let rootCatalog;
|
|
380
|
+
let rootUrl;
|
|
381
|
+
if (typeof root === "string") {
|
|
382
|
+
rootUrl = root;
|
|
383
|
+
rootCatalog = await fetchCatalog(root, fetchFn, timeoutMs);
|
|
384
|
+
} else {
|
|
385
|
+
rootCatalog = root;
|
|
386
|
+
rootUrl = opts.rootUrl ?? "(in-memory)";
|
|
387
|
+
}
|
|
388
|
+
const out = [];
|
|
389
|
+
const visited = /* @__PURE__ */ new Set([rootUrl]);
|
|
390
|
+
const walk = async (catalog, sourceUrl, depth) => {
|
|
391
|
+
for (const entry of catalog.entries) {
|
|
392
|
+
const isNested = entry.type === CATALOG_MEDIA_TYPE && typeof entry.url === "string";
|
|
393
|
+
if (isNested && depth < maxDepth) {
|
|
394
|
+
const childUrl = new URL(entry.url, sourceUrl === "(in-memory)" ? void 0 : sourceUrl).toString();
|
|
395
|
+
if (visited.has(childUrl)) continue;
|
|
396
|
+
visited.add(childUrl);
|
|
397
|
+
try {
|
|
398
|
+
const child = await fetchCatalog(childUrl, fetchFn, timeoutMs);
|
|
399
|
+
await walk(child, childUrl, depth + 1);
|
|
400
|
+
} catch {
|
|
401
|
+
out.push({ ...entry, sourceCatalogUrl: sourceUrl, depth });
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
out.push({ ...entry, sourceCatalogUrl: sourceUrl, depth });
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
await walk(rootCatalog, rootUrl, 0);
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
function resolveDisplayName(entry, referencedName) {
|
|
412
|
+
if (entry.displayName) return entry.displayName;
|
|
413
|
+
if (referencedName) return referencedName;
|
|
414
|
+
return identifierTail(entry.identifier);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/cli/index.ts
|
|
418
|
+
var VERSION = "0.1.0";
|
|
419
|
+
var c = {
|
|
420
|
+
red: (s) => `\x1B[31m${s}\x1B[0m`,
|
|
421
|
+
green: (s) => `\x1B[32m${s}\x1B[0m`,
|
|
422
|
+
yellow: (s) => `\x1B[33m${s}\x1B[0m`,
|
|
423
|
+
dim: (s) => `\x1B[2m${s}\x1B[0m`,
|
|
424
|
+
bold: (s) => `\x1B[1m${s}\x1B[0m`
|
|
425
|
+
};
|
|
426
|
+
var USAGE = `ai-catalog ${VERSION} \u2014 tooling for the Agentic Resource Discovery (ARD) spec
|
|
427
|
+
|
|
428
|
+
Usage:
|
|
429
|
+
ai-catalog init [path] Scaffold a .well-known/ai-catalog.json
|
|
430
|
+
ai-catalog validate <file> Validate a catalog file against the spec
|
|
431
|
+
ai-catalog discover <site-url> Discover the catalog a website advertises
|
|
432
|
+
ai-catalog resolve <url|site> Fetch a catalog and flatten nested catalogs
|
|
433
|
+
ai-catalog --help Show this help
|
|
434
|
+
|
|
435
|
+
Flags:
|
|
436
|
+
--strict (validate) treat RECOMMENDED warnings as errors
|
|
437
|
+
--json machine-readable JSON output
|
|
438
|
+
--names (resolve) print resolved display names
|
|
439
|
+
`;
|
|
440
|
+
function arg(argv, flag) {
|
|
441
|
+
return argv.includes(flag);
|
|
442
|
+
}
|
|
443
|
+
async function main(argv) {
|
|
444
|
+
const [cmd, ...rest] = argv;
|
|
445
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
446
|
+
process.stdout.write(USAGE);
|
|
447
|
+
return 0;
|
|
448
|
+
}
|
|
449
|
+
if (cmd === "--version" || cmd === "-v") {
|
|
450
|
+
process.stdout.write(`${VERSION}
|
|
451
|
+
`);
|
|
452
|
+
return 0;
|
|
453
|
+
}
|
|
454
|
+
switch (cmd) {
|
|
455
|
+
case "init":
|
|
456
|
+
return cmdInit(rest);
|
|
457
|
+
case "validate":
|
|
458
|
+
return cmdValidate(rest);
|
|
459
|
+
case "discover":
|
|
460
|
+
return cmdDiscover(rest);
|
|
461
|
+
case "resolve":
|
|
462
|
+
return cmdResolve(rest);
|
|
463
|
+
default:
|
|
464
|
+
process.stderr.write(c.red(`Unknown command: ${cmd}
|
|
465
|
+
|
|
466
|
+
`));
|
|
467
|
+
process.stdout.write(USAGE);
|
|
468
|
+
return 2;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async function cmdInit(rest) {
|
|
472
|
+
const path = rest.find((a) => !a.startsWith("-")) ?? `.${WELL_KNOWN_PATH}`;
|
|
473
|
+
if (existsSync(path)) {
|
|
474
|
+
process.stderr.write(c.red(`Refusing to overwrite existing file: ${path}
|
|
475
|
+
`));
|
|
476
|
+
return 1;
|
|
477
|
+
}
|
|
478
|
+
const sample = new CatalogBuilder({
|
|
479
|
+
displayName: "Your Organization",
|
|
480
|
+
identifier: "did:web:example.com",
|
|
481
|
+
documentationUrl: "https://example.com/docs"
|
|
482
|
+
}).addEntry({
|
|
483
|
+
identifier: "urn:air:example.com:mcp:weather",
|
|
484
|
+
type: "application/mcp-server-card+json",
|
|
485
|
+
description: "Weather lookup MCP server.",
|
|
486
|
+
tags: ["weather", "mcp"],
|
|
487
|
+
url: "https://example.com/.well-known/mcp/weather.json"
|
|
488
|
+
}).toJSON();
|
|
489
|
+
await mkdir(dirname(path), { recursive: true });
|
|
490
|
+
await writeFile(path, sample + "\n");
|
|
491
|
+
process.stdout.write(
|
|
492
|
+
`${c.green("created")} ${path}
|
|
493
|
+
` + c.dim(`Serve it at ${WELL_KNOWN_PATH} with content-type application/ai-catalog+json.
|
|
494
|
+
`)
|
|
495
|
+
);
|
|
496
|
+
return 0;
|
|
497
|
+
}
|
|
498
|
+
async function cmdValidate(rest) {
|
|
499
|
+
const file = rest.find((a) => !a.startsWith("-"));
|
|
500
|
+
if (!file) {
|
|
501
|
+
process.stderr.write(c.red("validate: missing <file>\n"));
|
|
502
|
+
return 2;
|
|
503
|
+
}
|
|
504
|
+
const strict = arg(rest, "--strict");
|
|
505
|
+
const json = arg(rest, "--json");
|
|
506
|
+
let doc;
|
|
507
|
+
try {
|
|
508
|
+
doc = JSON.parse(await readFile(file, "utf8"));
|
|
509
|
+
} catch (e) {
|
|
510
|
+
process.stderr.write(c.red(`Cannot read/parse ${file}: ${e.message}
|
|
511
|
+
`));
|
|
512
|
+
return 1;
|
|
513
|
+
}
|
|
514
|
+
const result = validateCatalog(doc, { strict });
|
|
515
|
+
if (json) {
|
|
516
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
517
|
+
return result.valid ? 0 : 1;
|
|
518
|
+
}
|
|
519
|
+
for (const e of result.errors) {
|
|
520
|
+
process.stdout.write(`${c.red("error")} ${c.bold(e.path || "/")} ${e.message}
|
|
521
|
+
`);
|
|
522
|
+
}
|
|
523
|
+
for (const w of result.warnings) {
|
|
524
|
+
process.stdout.write(`${c.yellow("warn")} ${c.bold(w.path || "/")} ${w.message}
|
|
525
|
+
`);
|
|
526
|
+
}
|
|
527
|
+
if (result.valid && result.warnings.length === 0) {
|
|
528
|
+
process.stdout.write(`${c.green("valid")} ${file} conforms to ARD spec v${SPEC_VERSION}
|
|
529
|
+
`);
|
|
530
|
+
} else if (result.valid) {
|
|
531
|
+
process.stdout.write(`${c.green("valid")} ${file} (${result.warnings.length} warning(s))
|
|
532
|
+
`);
|
|
533
|
+
} else {
|
|
534
|
+
process.stdout.write(
|
|
535
|
+
`${c.red("invalid")} ${result.errors.length} error(s), ${result.warnings.length} warning(s)
|
|
536
|
+
`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return result.valid ? 0 : 1;
|
|
540
|
+
}
|
|
541
|
+
async function cmdDiscover(rest) {
|
|
542
|
+
const site = rest.find((a) => !a.startsWith("-"));
|
|
543
|
+
if (!site) {
|
|
544
|
+
process.stderr.write(c.red("discover: missing <site-url>\n"));
|
|
545
|
+
return 2;
|
|
546
|
+
}
|
|
547
|
+
const json = arg(rest, "--json");
|
|
548
|
+
try {
|
|
549
|
+
const r = await discover(site);
|
|
550
|
+
if (json) {
|
|
551
|
+
process.stdout.write(JSON.stringify(r, null, 2) + "\n");
|
|
552
|
+
return 0;
|
|
553
|
+
}
|
|
554
|
+
process.stdout.write(
|
|
555
|
+
`${c.green("found")} ${r.url}
|
|
556
|
+
${c.dim(`via ${r.via}, ${r.catalog.entries.length} entr(ies)`)}
|
|
557
|
+
`
|
|
558
|
+
);
|
|
559
|
+
for (const e of r.catalog.entries) {
|
|
560
|
+
process.stdout.write(` ${c.bold(resolveDisplayName(e))} ${c.dim(e.type)}
|
|
561
|
+
`);
|
|
562
|
+
}
|
|
563
|
+
return 0;
|
|
564
|
+
} catch (e) {
|
|
565
|
+
process.stderr.write(c.red(`discover failed: ${e.message}
|
|
566
|
+
`));
|
|
567
|
+
return 1;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
async function cmdResolve(rest) {
|
|
571
|
+
const target = rest.find((a) => !a.startsWith("-"));
|
|
572
|
+
if (!target) {
|
|
573
|
+
process.stderr.write(c.red("resolve: missing <url|site>\n"));
|
|
574
|
+
return 2;
|
|
575
|
+
}
|
|
576
|
+
const json = arg(rest, "--json");
|
|
577
|
+
const names = arg(rest, "--names");
|
|
578
|
+
try {
|
|
579
|
+
const u = new URL(target);
|
|
580
|
+
const isBareSite = u.pathname === "/" || u.pathname === "";
|
|
581
|
+
const rootUrl = isBareSite ? (await discover(target)).url : target;
|
|
582
|
+
const entries = await resolveCatalog(rootUrl);
|
|
583
|
+
if (json) {
|
|
584
|
+
process.stdout.write(JSON.stringify(entries, null, 2) + "\n");
|
|
585
|
+
return 0;
|
|
586
|
+
}
|
|
587
|
+
process.stdout.write(`${c.green("resolved")} ${entries.length} entr(ies) from ${rootUrl}
|
|
588
|
+
`);
|
|
589
|
+
for (const e of entries) {
|
|
590
|
+
const label = names ? resolveDisplayName(e) : e.identifier;
|
|
591
|
+
process.stdout.write(` ${c.dim(`d${e.depth}`)} ${c.bold(label)} ${c.dim(e.type)}
|
|
592
|
+
`);
|
|
593
|
+
}
|
|
594
|
+
return 0;
|
|
595
|
+
} catch (e) {
|
|
596
|
+
process.stderr.write(c.red(`resolve failed: ${e.message}
|
|
597
|
+
`));
|
|
598
|
+
return 1;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
main(process.argv.slice(2)).then((code) => process.exit(code)).catch((e) => {
|
|
602
|
+
process.stderr.write(c.red(`fatal: ${e?.message ?? e}
|
|
603
|
+
`));
|
|
604
|
+
process.exit(1);
|
|
605
|
+
});
|