@pulsemcp/air-core 0.0.42 → 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/README.md +2 -2
- package/dist/config.d.ts +39 -22
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +363 -72
- package/dist/config.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/scope.d.ts +97 -0
- package/dist/scope.d.ts.map +1 -0
- package/dist/scope.js +174 -0
- package/dist/scope.js.map +1 -0
- package/dist/types.d.ts +30 -4
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/schemas/air.schema.json +13 -8
- package/schemas/schemas/air.schema.json +13 -8
package/README.md
CHANGED
|
@@ -24,8 +24,8 @@ const artifacts = await resolveArtifacts("~/.air/air.json");
|
|
|
24
24
|
// Validate a JSON file against its AIR schema
|
|
25
25
|
const result = validateJson(data, "skills");
|
|
26
26
|
|
|
27
|
-
// Merge two artifact sets (
|
|
28
|
-
const merged = mergeArtifacts(base,
|
|
27
|
+
// Merge two artifact sets (additive union — duplicate qualified IDs throw)
|
|
28
|
+
const merged = mergeArtifacts(base, overlay);
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
### With a Catalog Provider (remote URIs)
|
package/dist/config.d.ts
CHANGED
|
@@ -18,6 +18,11 @@ export interface ResolveOptions {
|
|
|
18
18
|
* (e.g., `gitProtocol`). Providers ignore unknown keys.
|
|
19
19
|
*/
|
|
20
20
|
providerOptions?: Record<string, unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* Sink for non-fatal warnings (e.g. cross-scope shortname collisions, stale
|
|
23
|
+
* `exclude` entries). When omitted, core writes warnings to `console.warn`.
|
|
24
|
+
*/
|
|
25
|
+
onWarning?: (message: string) => void;
|
|
21
26
|
}
|
|
22
27
|
/**
|
|
23
28
|
* Merge air.json-level provider fields with runtime overrides and dispatch
|
|
@@ -34,28 +39,40 @@ export interface ResolveOptions {
|
|
|
34
39
|
export declare function configureProviders(providers: CatalogProvider[], airConfig: AirConfig, providerOptions?: Record<string, unknown>): void;
|
|
35
40
|
/**
|
|
36
41
|
* Resolve all artifacts from an air.json file.
|
|
37
|
-
* Each artifact property is an array of paths; files merge in order.
|
|
38
|
-
* Remote URIs are delegated to the matching CatalogProvider.
|
|
39
42
|
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
43
|
+
* Every artifact is canonically `@scope/id`:
|
|
44
|
+
* - Catalog providers supply scope via `getScope(uri)` (e.g. the GitHub
|
|
45
|
+
* provider returns `owner/repo`).
|
|
46
|
+
* - Local catalogs and per-type arrays default to scope `local`.
|
|
47
|
+
*
|
|
48
|
+
* Composition is union-only: catalogs and per-type arrays *contribute*
|
|
49
|
+
* artifacts. The only way to remove an artifact is via `air.json#exclude`,
|
|
50
|
+
* which lists qualified IDs to drop from the resolved set. Two contributors
|
|
51
|
+
* producing the same qualified ID hard-fail; same shortname under different
|
|
52
|
+
* scopes warns and both qualified IDs appear in the result.
|
|
48
53
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
54
|
+
* Reference fields inside artifact bodies (skill.references, plugin.skills,
|
|
55
|
+
* root.default_skills, …) are canonicalized to qualified IDs at resolution
|
|
56
|
+
* time. References inside a catalog's own indexes resolve to that catalog's
|
|
57
|
+
* scope first; cross-catalog references must use the qualified form when
|
|
58
|
+
* the shortname is ambiguous.
|
|
59
|
+
*
|
|
60
|
+
* All `path` fields are absolute, making artifacts self-contained regardless
|
|
61
|
+
* of source location.
|
|
51
62
|
*/
|
|
52
63
|
export declare function resolveArtifacts(airJsonPath: string, options?: ResolveOptions): Promise<ResolvedArtifacts>;
|
|
53
64
|
/**
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
65
|
+
* Combine two resolved artifact sets by union. Inputs must already be
|
|
66
|
+
* qualified (`@scope/id`); a duplicate qualified ID across the two inputs
|
|
67
|
+
* is rejected with a clear diagnostic. Composite plugins are re-expanded
|
|
68
|
+
* after merging.
|
|
69
|
+
*
|
|
70
|
+
* `mergeArtifacts` is a low-level helper used to layer two pre-resolved
|
|
71
|
+
* sets — for example, an external orchestrator merging a parent session's
|
|
72
|
+
* artifacts with a subagent's. Most callers should compose at the air.json
|
|
73
|
+
* level (catalogs + exclude) instead.
|
|
57
74
|
*/
|
|
58
|
-
export declare function mergeArtifacts(base: ResolvedArtifacts,
|
|
75
|
+
export declare function mergeArtifacts(base: ResolvedArtifacts, overlay: ResolvedArtifacts): ResolvedArtifacts;
|
|
59
76
|
/**
|
|
60
77
|
* Recursively expand plugin references.
|
|
61
78
|
*
|
|
@@ -66,12 +83,12 @@ export declare function mergeArtifacts(base: ResolvedArtifacts, override: Resolv
|
|
|
66
83
|
* not a runtime concept.
|
|
67
84
|
*
|
|
68
85
|
* Semantics:
|
|
69
|
-
* -
|
|
70
|
-
* -
|
|
71
|
-
* -
|
|
72
|
-
* -
|
|
73
|
-
* -
|
|
74
|
-
*
|
|
86
|
+
* - All IDs are already qualified (`@scope/id`) at this stage.
|
|
87
|
+
* - Child plugins are expanded depth-first in declaration order.
|
|
88
|
+
* - Parent's direct declarations come last (later wins via dedup).
|
|
89
|
+
* - Circular references are rejected with a clear error message.
|
|
90
|
+
* - Plugins without a `plugins` field are returned unchanged.
|
|
91
|
+
* - The `plugins` array on each entry is preserved as metadata.
|
|
75
92
|
*
|
|
76
93
|
* Returns a new ResolvedArtifacts object; the input is not mutated.
|
|
77
94
|
*/
|
package/dist/config.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,SAAS,EACT,iBAAiB,EAOjB,eAAe,EAChB,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EACV,SAAS,EACT,iBAAiB,EAOjB,eAAe,EAChB,MAAM,YAAY,CAAC;AAkNpB,wBAAgB,aAAa,CAAC,WAAW,EAAE,MAAM,GAAG,SAAS,CAG5D;AAED;;GAEG;AACH,wBAAgB,qBAAqB,IAAI,MAAM,CAM9C;AAED;;;GAGG;AACH,wBAAgB,cAAc,IAAI,MAAM,GAAG,IAAI,CAG9C;AAED,MAAM,WAAW,cAAc;IAC7B,2EAA2E;IAC3E,SAAS,CAAC,EAAE,eAAe,EAAE,CAAC;IAC9B;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C;;;OAGG;IACH,SAAS,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CACvC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,kBAAkB,CAChC,SAAS,EAAE,eAAe,EAAE,EAC5B,SAAS,EAAE,SAAS,EACpB,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACxC,IAAI,CAwBN;AAufD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,cAAc,GACvB,OAAO,CAAC,iBAAiB,CAAC,CAgF5B;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC5B,IAAI,EAAE,iBAAiB,EACvB,OAAO,EAAE,iBAAiB,GACzB,iBAAiB,CA2BnB;AAmBD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,aAAa,CAAC,SAAS,EAAE,iBAAiB,GAAG,iBAAiB,CAiF7E;AAED,wBAAgB,cAAc,IAAI,iBAAiB,CASlD"}
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync, readdirSync, existsSync } from "fs";
|
|
|
2
2
|
import { resolve, dirname } from "path";
|
|
3
3
|
import ignore from "ignore";
|
|
4
4
|
import { detectSchemaType, detectSchemaFromValue, } from "./schemas.js";
|
|
5
|
+
import { deriveScope, qualifyId, parseQualifiedId, isQualified, resolveReference, } from "./scope.js";
|
|
5
6
|
function loadJsonFile(filePath) {
|
|
6
7
|
if (!existsSync(filePath)) {
|
|
7
8
|
return {};
|
|
@@ -43,16 +44,13 @@ function resolveEntryPaths(entries, sourceDir) {
|
|
|
43
44
|
return resolved;
|
|
44
45
|
}
|
|
45
46
|
/**
|
|
46
|
-
* Load
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* After loading, relative `path` fields in entries are resolved
|
|
51
|
-
* to absolute paths, so downstream consumers don't need source directory context.
|
|
47
|
+
* Load every contribution for a single artifact type and return them as a
|
|
48
|
+
* flat list. Each contribution preserves the scope of the catalog it came
|
|
49
|
+
* from so qualification can happen during merging.
|
|
52
50
|
*/
|
|
53
|
-
async function
|
|
54
|
-
|
|
55
|
-
for (const p of paths) {
|
|
51
|
+
async function loadContributions(paths, baseDir, providers) {
|
|
52
|
+
const contributions = [];
|
|
53
|
+
for (const { path: p, scope } of paths) {
|
|
56
54
|
const scheme = getScheme(p);
|
|
57
55
|
let data;
|
|
58
56
|
let sourceDir;
|
|
@@ -73,9 +71,81 @@ async function loadAndMerge(paths, baseDir, providers) {
|
|
|
73
71
|
}
|
|
74
72
|
const entries = stripSchema(data);
|
|
75
73
|
const resolved = resolveEntryPaths(entries, sourceDir);
|
|
76
|
-
|
|
74
|
+
contributions.push({ scope, source: p, entries: resolved });
|
|
75
|
+
}
|
|
76
|
+
return contributions;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Merge per-source contributions into a single `@scope/id`-keyed map.
|
|
80
|
+
*
|
|
81
|
+
* Composition rules:
|
|
82
|
+
* - Every entry is qualified `@scope/id` using the contribution's scope.
|
|
83
|
+
* - Two contributions producing the same qualified ID hard-fail with a
|
|
84
|
+
* diagnostic that names both sources.
|
|
85
|
+
* - Two contributions producing the same shortname under different scopes
|
|
86
|
+
* both land in the map. Cross-scope shortname collisions are reported by
|
|
87
|
+
* {@link warnCrossScopeShortnames} *after* `exclude` runs, so excluding
|
|
88
|
+
* one of the colliding artifacts silences the warning.
|
|
89
|
+
*
|
|
90
|
+
* Returns the merged map plus a `sources` map that records which contribution
|
|
91
|
+
* each qualified ID came from — used later for the post-exclude warning pass.
|
|
92
|
+
*/
|
|
93
|
+
function mergeContributions(contributions, artifactType) {
|
|
94
|
+
const merged = {};
|
|
95
|
+
const sourceByQualified = new Map();
|
|
96
|
+
for (const contribution of contributions) {
|
|
97
|
+
for (const [shortname, entry] of Object.entries(contribution.entries)) {
|
|
98
|
+
if (isQualified(shortname)) {
|
|
99
|
+
throw new Error(`Artifact index entry must use a bare shortname; got qualified ID ` +
|
|
100
|
+
`"${shortname}" in ${contribution.source}. Scopes are assigned by ` +
|
|
101
|
+
`the catalog source, not by authors.`);
|
|
102
|
+
}
|
|
103
|
+
const qualified = qualifyId(contribution.scope, shortname);
|
|
104
|
+
const existingSource = sourceByQualified.get(qualified);
|
|
105
|
+
if (existingSource !== undefined) {
|
|
106
|
+
throw new Error(`Duplicate ${artifactType} ID "${qualified}" produced by both ` +
|
|
107
|
+
`"${existingSource}" and "${contribution.source}". Two catalogs ` +
|
|
108
|
+
`with the same scope contributed the same shortname; rename one ` +
|
|
109
|
+
`or remove the duplicate from your air.json.`);
|
|
110
|
+
}
|
|
111
|
+
sourceByQualified.set(qualified, contribution.source);
|
|
112
|
+
merged[qualified] = entry;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { merged, sources: sourceByQualified };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Emit one warning per shortname that survives `exclude` under more than one
|
|
119
|
+
* scope. Running this after {@link applyExclude} means excluding either side
|
|
120
|
+
* of the collision silences the warning.
|
|
121
|
+
*/
|
|
122
|
+
function warnCrossScopeShortnames(artifacts, sourcesByType, warnings) {
|
|
123
|
+
for (const type of ARTIFACT_TYPES) {
|
|
124
|
+
const pool = artifacts[type];
|
|
125
|
+
const sources = sourcesByType[type];
|
|
126
|
+
const scopesByShortname = new Map();
|
|
127
|
+
for (const qualified of Object.keys(pool)) {
|
|
128
|
+
const { scope, id: shortname } = parseQualifiedId(qualified);
|
|
129
|
+
const source = sources.get(qualified) ?? "(unknown source)";
|
|
130
|
+
let scopeMap = scopesByShortname.get(shortname);
|
|
131
|
+
if (!scopeMap) {
|
|
132
|
+
scopeMap = new Map();
|
|
133
|
+
scopesByShortname.set(shortname, scopeMap);
|
|
134
|
+
}
|
|
135
|
+
scopeMap.set(scope, source);
|
|
136
|
+
}
|
|
137
|
+
for (const [shortname, scopeMap] of scopesByShortname) {
|
|
138
|
+
if (scopeMap.size <= 1)
|
|
139
|
+
continue;
|
|
140
|
+
const scopeList = [...scopeMap.entries()]
|
|
141
|
+
.map(([scope, source]) => `@${scope} (from ${source})`)
|
|
142
|
+
.join(", ");
|
|
143
|
+
warnings.push(`Cross-scope shortname collision: ${type} "${shortname}" is ` +
|
|
144
|
+
`provided by ${scopeMap.size} scopes — ${scopeList}. Short references ` +
|
|
145
|
+
`to "${shortname}" without a scope are ambiguous; use the qualified ` +
|
|
146
|
+
`form "@scope/${shortname}" to disambiguate.`);
|
|
147
|
+
}
|
|
77
148
|
}
|
|
78
|
-
return merged;
|
|
79
149
|
}
|
|
80
150
|
export function loadAirConfig(airJsonPath) {
|
|
81
151
|
const content = readFileSync(airJsonPath, "utf-8");
|
|
@@ -205,9 +275,7 @@ function detectIndexType(absPath, filename) {
|
|
|
205
275
|
* within `CATALOG_MAX_DEPTH` levels, skipping `CATALOG_SKIP_DIRS`, hidden
|
|
206
276
|
* entries, and anything matched by a root-level `.gitignore` if present.
|
|
207
277
|
*
|
|
208
|
-
* Files are returned sorted by relative path
|
|
209
|
-
* that "later wins" collision semantics within a single catalog are stable
|
|
210
|
-
* across machines and filesystems.
|
|
278
|
+
* Files are returned sorted by relative path.
|
|
211
279
|
*/
|
|
212
280
|
function discoverCatalogIndexes(catalogDir) {
|
|
213
281
|
if (!existsSync(catalogDir))
|
|
@@ -288,88 +356,311 @@ async function resolveCatalogRoot(catalog, baseDir, providers) {
|
|
|
288
356
|
return await provider.resolveCatalogDir(catalog);
|
|
289
357
|
}
|
|
290
358
|
/**
|
|
291
|
-
* Expand every entry in `catalogs[]` into
|
|
292
|
-
*
|
|
293
|
-
* artifact index files (depth-capped, gitignore-aware, skip-listed)
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
* locations are merged in sorted relPath order with later-wins semantics —
|
|
297
|
-
* the downstream `loadAndMerge` consumes the returned arrays in order and
|
|
298
|
-
* applies the same "later wins by ID" rule as everywhere else in AIR.
|
|
299
|
-
*
|
|
300
|
-
* Across catalogs, catalogs earlier in `catalogs[]` are processed first so
|
|
301
|
-
* that later catalogs override earlier ones, matching the existing contract.
|
|
359
|
+
* Expand every entry in `catalogs[]` into per-type index file paths grouped
|
|
360
|
+
* by scope. Each catalog is resolved to a local directory and walked for
|
|
361
|
+
* artifact index files (depth-capped, gitignore-aware, skip-listed); the
|
|
362
|
+
* scope assigned to those files comes from the provider's `getScope(uri)`,
|
|
363
|
+
* or `local` for filesystem catalogs and providers without `getScope`.
|
|
302
364
|
*/
|
|
303
365
|
async function expandAllCatalogs(catalogs, baseDir, providers) {
|
|
304
|
-
const
|
|
305
|
-
skills: [],
|
|
306
|
-
references: [],
|
|
307
|
-
mcp: [],
|
|
308
|
-
plugins: [],
|
|
309
|
-
roots: [],
|
|
310
|
-
hooks: [],
|
|
311
|
-
};
|
|
366
|
+
const expansions = [];
|
|
312
367
|
for (const catalog of catalogs) {
|
|
313
368
|
const catalogDir = await resolveCatalogRoot(catalog, baseDir, providers);
|
|
314
369
|
const discovered = discoverCatalogIndexes(catalogDir);
|
|
370
|
+
const scope = deriveScope(catalog, providers);
|
|
371
|
+
const paths = {
|
|
372
|
+
skills: [],
|
|
373
|
+
references: [],
|
|
374
|
+
mcp: [],
|
|
375
|
+
plugins: [],
|
|
376
|
+
roots: [],
|
|
377
|
+
hooks: [],
|
|
378
|
+
};
|
|
315
379
|
for (const entry of discovered) {
|
|
316
|
-
|
|
380
|
+
paths[entry.type].push(entry.absPath);
|
|
381
|
+
}
|
|
382
|
+
expansions.push({ scope, paths });
|
|
383
|
+
}
|
|
384
|
+
return expansions;
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Canonicalize every reference field in an artifact body to its qualified
|
|
388
|
+
* form. References that fail to resolve (missing or ambiguous) populate
|
|
389
|
+
* `errors` so callers can fail composition with all problems at once.
|
|
390
|
+
*
|
|
391
|
+
* `fromScope` is the scope of the artifact that owns the reference — used
|
|
392
|
+
* to apply the intra-catalog rule (a short reference inside a catalog binds
|
|
393
|
+
* to that catalog's scope first).
|
|
394
|
+
*/
|
|
395
|
+
function canonicalizeReferences(artifacts, excluded, errors) {
|
|
396
|
+
const result = {
|
|
397
|
+
skills: { ...artifacts.skills },
|
|
398
|
+
references: { ...artifacts.references },
|
|
399
|
+
mcp: { ...artifacts.mcp },
|
|
400
|
+
plugins: { ...artifacts.plugins },
|
|
401
|
+
roots: { ...artifacts.roots },
|
|
402
|
+
hooks: { ...artifacts.hooks },
|
|
403
|
+
};
|
|
404
|
+
/**
|
|
405
|
+
* For a `missing` reference, decide whether the target was specifically
|
|
406
|
+
* dropped by `air.json#exclude`. Exact qualified-ID match wins; otherwise a
|
|
407
|
+
* short reference matches any excluded entry whose shortname matches `ref`.
|
|
408
|
+
* The list of matching excluded IDs is returned so the error can name them.
|
|
409
|
+
*/
|
|
410
|
+
function excludedMatches(ref) {
|
|
411
|
+
if (isQualified(ref)) {
|
|
412
|
+
return excluded.has(ref) ? [ref] : [];
|
|
413
|
+
}
|
|
414
|
+
const matches = [];
|
|
415
|
+
for (const id of excluded) {
|
|
416
|
+
if (parseQualifiedId(id).id === ref)
|
|
417
|
+
matches.push(id);
|
|
418
|
+
}
|
|
419
|
+
return matches;
|
|
420
|
+
}
|
|
421
|
+
function resolveList(list, pool, fromScope, poolType, ownerLabel, field) {
|
|
422
|
+
if (!list)
|
|
423
|
+
return undefined;
|
|
424
|
+
const out = [];
|
|
425
|
+
for (const ref of list) {
|
|
426
|
+
const res = resolveReference(pool, ref, fromScope);
|
|
427
|
+
if (res.status === "ok") {
|
|
428
|
+
out.push(res.qualified);
|
|
429
|
+
}
|
|
430
|
+
else if (res.status === "missing") {
|
|
431
|
+
const matches = excludedMatches(ref);
|
|
432
|
+
if (matches.length > 0) {
|
|
433
|
+
errors.push(`${ownerLabel}.${field} references ${poolType} "${ref}", ` +
|
|
434
|
+
`which is removed by air.json#exclude (${matches.join(", ")}). ` +
|
|
435
|
+
`Drop the exclude entry or also remove every artifact that ` +
|
|
436
|
+
`references it.`);
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
errors.push(`${ownerLabel}.${field} references unknown ${poolType} "${ref}". ` +
|
|
440
|
+
`Available qualified IDs: ${listIds(pool)}.`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
errors.push(`${ownerLabel}.${field} reference "${ref}" is ambiguous across ` +
|
|
445
|
+
`scopes — candidates: ${res.candidates.join(", ")}. ` +
|
|
446
|
+
`Use the qualified form to disambiguate.`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
return out;
|
|
450
|
+
}
|
|
451
|
+
function processOwner(qualified, owner) {
|
|
452
|
+
const { scope } = parseQualifiedId(qualified);
|
|
453
|
+
const ownerLabel = qualified;
|
|
454
|
+
if (owner.kind === "skill" || owner.kind === "hook") {
|
|
455
|
+
const next = { ...owner.entry };
|
|
456
|
+
next.references = resolveList(next.references, result.references, scope, "reference", ownerLabel, "references");
|
|
457
|
+
if (owner.kind === "skill")
|
|
458
|
+
result.skills[qualified] = next;
|
|
459
|
+
else
|
|
460
|
+
result.hooks[qualified] = next;
|
|
461
|
+
}
|
|
462
|
+
else if (owner.kind === "plugin") {
|
|
463
|
+
const next = { ...owner.entry };
|
|
464
|
+
next.skills = resolveList(next.skills, result.skills, scope, "skill", ownerLabel, "skills");
|
|
465
|
+
next.mcp_servers = resolveList(next.mcp_servers, result.mcp, scope, "mcp", ownerLabel, "mcp_servers");
|
|
466
|
+
next.hooks = resolveList(next.hooks, result.hooks, scope, "hook", ownerLabel, "hooks");
|
|
467
|
+
next.plugins = resolveList(next.plugins, result.plugins, scope, "plugin", ownerLabel, "plugins");
|
|
468
|
+
result.plugins[qualified] = next;
|
|
317
469
|
}
|
|
470
|
+
else {
|
|
471
|
+
const next = { ...owner.entry };
|
|
472
|
+
next.default_skills = resolveList(next.default_skills, result.skills, scope, "skill", ownerLabel, "default_skills");
|
|
473
|
+
next.default_mcp_servers = resolveList(next.default_mcp_servers, result.mcp, scope, "mcp", ownerLabel, "default_mcp_servers");
|
|
474
|
+
next.default_plugins = resolveList(next.default_plugins, result.plugins, scope, "plugin", ownerLabel, "default_plugins");
|
|
475
|
+
next.default_hooks = resolveList(next.default_hooks, result.hooks, scope, "hook", ownerLabel, "default_hooks");
|
|
476
|
+
next.default_subagent_roots = resolveList(next.default_subagent_roots, result.roots, scope, "root", ownerLabel, "default_subagent_roots");
|
|
477
|
+
result.roots[qualified] = next;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
for (const [qualified, entry] of Object.entries(artifacts.skills)) {
|
|
481
|
+
processOwner(qualified, { kind: "skill", entry });
|
|
482
|
+
}
|
|
483
|
+
for (const [qualified, entry] of Object.entries(artifacts.hooks)) {
|
|
484
|
+
processOwner(qualified, { kind: "hook", entry });
|
|
485
|
+
}
|
|
486
|
+
for (const [qualified, entry] of Object.entries(artifacts.plugins)) {
|
|
487
|
+
processOwner(qualified, { kind: "plugin", entry });
|
|
488
|
+
}
|
|
489
|
+
for (const [qualified, entry] of Object.entries(artifacts.roots)) {
|
|
490
|
+
processOwner(qualified, { kind: "root", entry });
|
|
318
491
|
}
|
|
319
492
|
return result;
|
|
320
493
|
}
|
|
494
|
+
function listIds(pool) {
|
|
495
|
+
const keys = Object.keys(pool);
|
|
496
|
+
if (keys.length === 0)
|
|
497
|
+
return "(none)";
|
|
498
|
+
if (keys.length > 8) {
|
|
499
|
+
return `${keys.slice(0, 8).join(", ")}, … (${keys.length} total)`;
|
|
500
|
+
}
|
|
501
|
+
return keys.join(", ");
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Apply `air.json#exclude` to a resolved artifact set. Excluded qualified IDs
|
|
505
|
+
* disappear from every artifact map; entries that don't match anything are
|
|
506
|
+
* surfaced as warnings so typos in `exclude` are visible to authors.
|
|
507
|
+
*/
|
|
508
|
+
function applyExclude(artifacts, exclude, warnings) {
|
|
509
|
+
if (exclude.length === 0) {
|
|
510
|
+
return { artifacts, excluded: new Set() };
|
|
511
|
+
}
|
|
512
|
+
const excludeSet = new Set();
|
|
513
|
+
for (const id of exclude) {
|
|
514
|
+
if (!isQualified(id)) {
|
|
515
|
+
throw new Error(`air.json "exclude" entries must be qualified IDs (@scope/id); got "${id}".`);
|
|
516
|
+
}
|
|
517
|
+
excludeSet.add(id);
|
|
518
|
+
}
|
|
519
|
+
const result = {
|
|
520
|
+
skills: {},
|
|
521
|
+
references: {},
|
|
522
|
+
mcp: {},
|
|
523
|
+
plugins: {},
|
|
524
|
+
roots: {},
|
|
525
|
+
hooks: {},
|
|
526
|
+
};
|
|
527
|
+
const seen = new Set();
|
|
528
|
+
for (const type of ARTIFACT_TYPES) {
|
|
529
|
+
const src = artifacts[type];
|
|
530
|
+
const dst = result[type];
|
|
531
|
+
for (const [id, entry] of Object.entries(src)) {
|
|
532
|
+
if (excludeSet.has(id)) {
|
|
533
|
+
seen.add(id);
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
dst[id] = entry;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
for (const id of excludeSet) {
|
|
540
|
+
if (!seen.has(id)) {
|
|
541
|
+
warnings.push(`air.json "exclude" entry "${id}" did not match any resolved ` +
|
|
542
|
+
`artifact. Remove it or check for typos.`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
return { artifacts: result, excluded: excludeSet };
|
|
546
|
+
}
|
|
321
547
|
/**
|
|
322
548
|
* Resolve all artifacts from an air.json file.
|
|
323
|
-
* Each artifact property is an array of paths; files merge in order.
|
|
324
|
-
* Remote URIs are delegated to the matching CatalogProvider.
|
|
325
549
|
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
550
|
+
* Every artifact is canonically `@scope/id`:
|
|
551
|
+
* - Catalog providers supply scope via `getScope(uri)` (e.g. the GitHub
|
|
552
|
+
* provider returns `owner/repo`).
|
|
553
|
+
* - Local catalogs and per-type arrays default to scope `local`.
|
|
554
|
+
*
|
|
555
|
+
* Composition is union-only: catalogs and per-type arrays *contribute*
|
|
556
|
+
* artifacts. The only way to remove an artifact is via `air.json#exclude`,
|
|
557
|
+
* which lists qualified IDs to drop from the resolved set. Two contributors
|
|
558
|
+
* producing the same qualified ID hard-fail; same shortname under different
|
|
559
|
+
* scopes warns and both qualified IDs appear in the result.
|
|
560
|
+
*
|
|
561
|
+
* Reference fields inside artifact bodies (skill.references, plugin.skills,
|
|
562
|
+
* root.default_skills, …) are canonicalized to qualified IDs at resolution
|
|
563
|
+
* time. References inside a catalog's own indexes resolve to that catalog's
|
|
564
|
+
* scope first; cross-catalog references must use the qualified form when
|
|
565
|
+
* the shortname is ambiguous.
|
|
334
566
|
*
|
|
335
|
-
* All `path` fields
|
|
336
|
-
*
|
|
567
|
+
* All `path` fields are absolute, making artifacts self-contained regardless
|
|
568
|
+
* of source location.
|
|
337
569
|
*/
|
|
338
570
|
export async function resolveArtifacts(airJsonPath, options) {
|
|
339
571
|
const airConfig = loadAirConfig(airJsonPath);
|
|
340
572
|
const baseDir = dirname(resolve(airJsonPath));
|
|
341
573
|
const providers = options?.providers || [];
|
|
342
574
|
const catalogs = airConfig.catalogs || [];
|
|
575
|
+
const onWarning = options?.onWarning ?? ((msg) => console.warn(`warning: ${msg}`));
|
|
576
|
+
const warnings = [];
|
|
343
577
|
// Configure providers with merged options: air.json fields are the base,
|
|
344
578
|
// explicit providerOptions override them. Providers ignore unknown keys.
|
|
345
579
|
configureProviders(providers, airConfig, options?.providerOptions);
|
|
346
|
-
const
|
|
347
|
-
function
|
|
348
|
-
|
|
580
|
+
const expansions = await expandAllCatalogs(catalogs, baseDir, providers);
|
|
581
|
+
function pathsFor(type) {
|
|
582
|
+
const list = [];
|
|
583
|
+
for (const expansion of expansions) {
|
|
584
|
+
for (const p of expansion.paths[type]) {
|
|
585
|
+
list.push({ path: p, scope: expansion.scope });
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
for (const p of airConfig[type] || []) {
|
|
589
|
+
// Per-type arrays default to LOCAL_SCOPE, but a provider URI in a
|
|
590
|
+
// per-type array still gets its provider scope so artifacts from
|
|
591
|
+
// different orgs/repos cannot collide under @local/<id>.
|
|
592
|
+
list.push({ path: p, scope: deriveScope(p, providers) });
|
|
593
|
+
}
|
|
594
|
+
return list;
|
|
595
|
+
}
|
|
596
|
+
const sourcesByType = {
|
|
597
|
+
skills: new Map(),
|
|
598
|
+
references: new Map(),
|
|
599
|
+
mcp: new Map(),
|
|
600
|
+
plugins: new Map(),
|
|
601
|
+
roots: new Map(),
|
|
602
|
+
hooks: new Map(),
|
|
603
|
+
};
|
|
604
|
+
async function load(type) {
|
|
605
|
+
const paths = pathsFor(type);
|
|
606
|
+
const contributions = await loadContributions(paths, baseDir, providers);
|
|
607
|
+
const { merged, sources } = mergeContributions(contributions, type);
|
|
608
|
+
sourcesByType[type] = sources;
|
|
609
|
+
return merged;
|
|
349
610
|
}
|
|
350
611
|
const resolved = {
|
|
351
|
-
skills: await
|
|
352
|
-
references: await
|
|
353
|
-
mcp: await
|
|
354
|
-
plugins: await
|
|
355
|
-
roots: await
|
|
356
|
-
hooks: await
|
|
612
|
+
skills: await load("skills"),
|
|
613
|
+
references: await load("references"),
|
|
614
|
+
mcp: await load("mcp"),
|
|
615
|
+
plugins: await load("plugins"),
|
|
616
|
+
roots: await load("roots"),
|
|
617
|
+
hooks: await load("hooks"),
|
|
357
618
|
};
|
|
358
|
-
|
|
619
|
+
// Apply exclude before reference canonicalization so dropped IDs cannot
|
|
620
|
+
// satisfy references and a clear "missing reference" error surfaces.
|
|
621
|
+
const { artifacts: filtered, excluded } = applyExclude(resolved, airConfig.exclude || [], warnings);
|
|
622
|
+
// Cross-scope shortname collisions are reported on the post-exclude pool, so
|
|
623
|
+
// excluding one side of the collision silences the warning automatically.
|
|
624
|
+
warnCrossScopeShortnames(filtered, sourcesByType, warnings);
|
|
625
|
+
const refErrors = [];
|
|
626
|
+
const canonical = canonicalizeReferences(filtered, excluded, refErrors);
|
|
627
|
+
if (refErrors.length > 0) {
|
|
628
|
+
throw new Error(`Reference resolution failed:\n - ${refErrors.join("\n - ")}`);
|
|
629
|
+
}
|
|
630
|
+
for (const w of warnings)
|
|
631
|
+
onWarning(w);
|
|
632
|
+
return expandPlugins(canonical);
|
|
359
633
|
}
|
|
360
634
|
/**
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
*
|
|
635
|
+
* Combine two resolved artifact sets by union. Inputs must already be
|
|
636
|
+
* qualified (`@scope/id`); a duplicate qualified ID across the two inputs
|
|
637
|
+
* is rejected with a clear diagnostic. Composite plugins are re-expanded
|
|
638
|
+
* after merging.
|
|
639
|
+
*
|
|
640
|
+
* `mergeArtifacts` is a low-level helper used to layer two pre-resolved
|
|
641
|
+
* sets — for example, an external orchestrator merging a parent session's
|
|
642
|
+
* artifacts with a subagent's. Most callers should compose at the air.json
|
|
643
|
+
* level (catalogs + exclude) instead.
|
|
364
644
|
*/
|
|
365
|
-
export function mergeArtifacts(base,
|
|
645
|
+
export function mergeArtifacts(base, overlay) {
|
|
646
|
+
function unionOrThrow(a, b, label) {
|
|
647
|
+
const out = { ...a };
|
|
648
|
+
for (const [k, v] of Object.entries(b)) {
|
|
649
|
+
if (k in out) {
|
|
650
|
+
throw new Error(`mergeArtifacts: duplicate ${label} ID "${k}" in both base and overlay. ` +
|
|
651
|
+
`Override is not supported — drop one source via air.json#exclude.`);
|
|
652
|
+
}
|
|
653
|
+
out[k] = v;
|
|
654
|
+
}
|
|
655
|
+
return out;
|
|
656
|
+
}
|
|
366
657
|
return expandPlugins({
|
|
367
|
-
skills:
|
|
368
|
-
references:
|
|
369
|
-
mcp:
|
|
370
|
-
plugins:
|
|
371
|
-
roots:
|
|
372
|
-
hooks:
|
|
658
|
+
skills: unionOrThrow(base.skills, overlay.skills, "skill"),
|
|
659
|
+
references: unionOrThrow(base.references, overlay.references, "reference"),
|
|
660
|
+
mcp: unionOrThrow(base.mcp, overlay.mcp, "mcp"),
|
|
661
|
+
plugins: unionOrThrow(base.plugins, overlay.plugins, "plugin"),
|
|
662
|
+
roots: unionOrThrow(base.roots, overlay.roots, "root"),
|
|
663
|
+
hooks: unionOrThrow(base.hooks, overlay.hooks, "hook"),
|
|
373
664
|
});
|
|
374
665
|
}
|
|
375
666
|
/**
|
|
@@ -398,12 +689,12 @@ function deduplicateIds(arr) {
|
|
|
398
689
|
* not a runtime concept.
|
|
399
690
|
*
|
|
400
691
|
* Semantics:
|
|
401
|
-
* -
|
|
402
|
-
* -
|
|
403
|
-
* -
|
|
404
|
-
* -
|
|
405
|
-
* -
|
|
406
|
-
*
|
|
692
|
+
* - All IDs are already qualified (`@scope/id`) at this stage.
|
|
693
|
+
* - Child plugins are expanded depth-first in declaration order.
|
|
694
|
+
* - Parent's direct declarations come last (later wins via dedup).
|
|
695
|
+
* - Circular references are rejected with a clear error message.
|
|
696
|
+
* - Plugins without a `plugins` field are returned unchanged.
|
|
697
|
+
* - The `plugins` array on each entry is preserved as metadata.
|
|
407
698
|
*
|
|
408
699
|
* Returns a new ResolvedArtifacts object; the input is not mutated.
|
|
409
700
|
*/
|