@apitap/core 1.5.3 → 1.6.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.
Files changed (60) hide show
  1. package/README.md +28 -8
  2. package/dist/auth/handoff.js +1 -1
  3. package/dist/auth/handoff.js.map +1 -1
  4. package/dist/capture/cdp-attach.d.ts +60 -0
  5. package/dist/capture/cdp-attach.js +422 -0
  6. package/dist/capture/cdp-attach.js.map +1 -0
  7. package/dist/capture/filter.js +6 -0
  8. package/dist/capture/filter.js.map +1 -1
  9. package/dist/capture/parameterize.d.ts +7 -6
  10. package/dist/capture/parameterize.js +204 -12
  11. package/dist/capture/parameterize.js.map +1 -1
  12. package/dist/capture/session.js +20 -10
  13. package/dist/capture/session.js.map +1 -1
  14. package/dist/cli.js +387 -20
  15. package/dist/cli.js.map +1 -1
  16. package/dist/discovery/openapi.js +23 -50
  17. package/dist/discovery/openapi.js.map +1 -1
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.js +1 -0
  20. package/dist/index.js.map +1 -1
  21. package/dist/mcp.js +12 -0
  22. package/dist/mcp.js.map +1 -1
  23. package/dist/native-host.js +5 -0
  24. package/dist/native-host.js.map +1 -1
  25. package/dist/plugin.js +10 -3
  26. package/dist/plugin.js.map +1 -1
  27. package/dist/replay/engine.d.ts +13 -0
  28. package/dist/replay/engine.js +20 -0
  29. package/dist/replay/engine.js.map +1 -1
  30. package/dist/skill/apis-guru.d.ts +35 -0
  31. package/dist/skill/apis-guru.js +128 -0
  32. package/dist/skill/apis-guru.js.map +1 -0
  33. package/dist/skill/generator.d.ts +7 -1
  34. package/dist/skill/generator.js +35 -3
  35. package/dist/skill/generator.js.map +1 -1
  36. package/dist/skill/merge.d.ts +29 -0
  37. package/dist/skill/merge.js +252 -0
  38. package/dist/skill/merge.js.map +1 -0
  39. package/dist/skill/openapi-converter.d.ts +31 -0
  40. package/dist/skill/openapi-converter.js +383 -0
  41. package/dist/skill/openapi-converter.js.map +1 -0
  42. package/dist/types.d.ts +41 -0
  43. package/package.json +1 -1
  44. package/src/auth/handoff.ts +1 -1
  45. package/src/capture/cdp-attach.ts +501 -0
  46. package/src/capture/filter.ts +5 -0
  47. package/src/capture/parameterize.ts +207 -11
  48. package/src/capture/session.ts +20 -10
  49. package/src/cli.ts +420 -18
  50. package/src/discovery/openapi.ts +25 -56
  51. package/src/index.ts +1 -0
  52. package/src/mcp.ts +12 -0
  53. package/src/native-host.ts +7 -0
  54. package/src/plugin.ts +10 -3
  55. package/src/replay/engine.ts +19 -0
  56. package/src/skill/apis-guru.ts +163 -0
  57. package/src/skill/generator.ts +38 -3
  58. package/src/skill/merge.ts +281 -0
  59. package/src/skill/openapi-converter.ts +426 -0
  60. package/src/types.ts +42 -1
package/src/plugin.ts CHANGED
@@ -3,6 +3,7 @@ import { searchSkills } from './skill/search.js';
3
3
  import { readSkillFile } from './skill/store.js';
4
4
  import { replayEndpoint } from './replay/engine.js';
5
5
  import { AuthManager, getMachineId } from './auth/manager.js';
6
+ import { deriveSigningKey } from './auth/crypto.js';
6
7
  import { homedir } from 'node:os';
7
8
  import { join } from 'node:path';
8
9
 
@@ -29,7 +30,11 @@ const APITAP_DIR = join(homedir(), '.apitap');
29
30
 
30
31
  /** M20: Mark plugin responses as untrusted external content */
31
32
  function wrapUntrusted(data: unknown): unknown {
32
- return { ...data as Record<string, unknown>, _meta: { externalContent: { untrusted: true } } };
33
+ const meta = { externalContent: { untrusted: true } };
34
+ if (Array.isArray(data)) {
35
+ return { results: data, _meta: meta };
36
+ }
37
+ return { ...data as Record<string, unknown>, _meta: meta };
33
38
  }
34
39
 
35
40
  export function createPlugin(options: PluginOptions = {}): Plugin {
@@ -93,7 +98,9 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
93
98
  const endpointId = args.endpointId as string;
94
99
  const params = args.params as Record<string, string> | undefined;
95
100
 
96
- const skill = await readSkillFile(domain, skillsDir, { trustUnsigned: true });
101
+ const machineId = await getMachineId();
102
+ const signingKey = deriveSigningKey(machineId);
103
+ const skill = await readSkillFile(domain, skillsDir, { verifySignature: true, signingKey, trustUnsigned: true });
97
104
  if (!skill) {
98
105
  return {
99
106
  error: `No skill file found for "${domain}". Use apitap_capture to capture it first.`,
@@ -159,7 +166,7 @@ export function createPlugin(options: PluginOptions = {}): Plugin {
159
166
  const { promisify } = await import('node:util');
160
167
  const execFileAsync = promisify(execFile);
161
168
 
162
- const cliArgs = ['--import', 'tsx', 'src/cli.ts', 'capture', url, '--duration', String(duration), '--json', '--no-verify'];
169
+ const cliArgs = ['--import', 'tsx', 'src/cli.ts', 'capture', url, '--duration', String(duration), '--json'];
163
170
  if (allDomains) cliArgs.push('--all-domains');
164
171
 
165
172
  try {
@@ -204,6 +204,24 @@ function wrapAuthError(
204
204
  };
205
205
  }
206
206
 
207
+ /**
208
+ * Returns a user-facing hint about confidence level, or null if confidence is high enough.
209
+ */
210
+ export function getConfidenceHint(confidence: number | undefined): string | null {
211
+ const c = confidence ?? 1.0;
212
+ if (c >= 0.85) return null;
213
+ if (c >= 0.7) return '(imported from spec — params may need adjustment)';
214
+ return '(imported from spec — provide params explicitly, no captured examples available)';
215
+ }
216
+
217
+ /**
218
+ * Returns true if a query param should be omitted from the request.
219
+ * Omits spec-derived params that have no real example value.
220
+ */
221
+ export function shouldOmitQueryParam(param: { type: string; example: string; fromSpec?: boolean }): boolean {
222
+ return param.fromSpec === true && param.example === '';
223
+ }
224
+
207
225
  /**
208
226
  * Replay a captured API endpoint.
209
227
  *
@@ -240,6 +258,7 @@ export async function replayEndpoint(
240
258
 
241
259
  // Apply query params: start with captured defaults, override with provided params
242
260
  for (const [key, val] of Object.entries(endpoint.queryParams)) {
261
+ if (shouldOmitQueryParam(val)) continue;
243
262
  url.searchParams.set(key, val.example);
244
263
  }
245
264
  if (params) {
@@ -0,0 +1,163 @@
1
+ // src/skill/apis-guru.ts
2
+ import { resolveAndValidateUrl } from '../skill/ssrf.js';
3
+
4
+ export interface ApisGuruEntry {
5
+ apiId: string; // e.g., "twilio.com:api"
6
+ providerName: string; // e.g., "twilio.com"
7
+ title: string;
8
+ specUrl: string; // direct URL to OpenAPI JSON spec
9
+ openapiVer: string;
10
+ updated: string;
11
+ }
12
+
13
+ const APIS_GURU_LIST_URL = 'https://api.apis.guru/v2/list.json';
14
+
15
+ /**
16
+ * Parse raw APIs.guru list.json response into ApisGuruEntry array.
17
+ * For each API, use the preferred version's data.
18
+ */
19
+ export function parseApisGuruList(raw: Record<string, any>): ApisGuruEntry[] {
20
+ const entries: ApisGuruEntry[] = [];
21
+
22
+ for (const apiId of Object.keys(raw)) {
23
+ const apiData = raw[apiId];
24
+ if (!apiData || typeof apiData !== 'object') continue;
25
+
26
+ const preferred: string = apiData.preferred;
27
+ if (!preferred) continue;
28
+
29
+ const versions = apiData.versions;
30
+ if (!versions || typeof versions !== 'object') continue;
31
+
32
+ const versionData = versions[preferred];
33
+ if (!versionData || typeof versionData !== 'object') continue;
34
+
35
+ const swaggerUrl: string | undefined = versionData.swaggerUrl;
36
+ if (!swaggerUrl) continue;
37
+
38
+ const info = versionData.info ?? {};
39
+ const title: string = info.title ?? '';
40
+ const openapiVer: string = versionData.openapiVer ?? '';
41
+ const updated: string = versionData.updated ?? '';
42
+
43
+ // providerName: prefer info.x-providerName, else split apiId on ':', else use apiId
44
+ let providerName: string;
45
+ if (info['x-providerName']) {
46
+ providerName = info['x-providerName'];
47
+ } else {
48
+ const colonIdx = apiId.indexOf(':');
49
+ providerName = colonIdx >= 0 ? apiId.slice(0, colonIdx) : apiId;
50
+ }
51
+
52
+ entries.push({
53
+ apiId,
54
+ providerName,
55
+ title,
56
+ specUrl: swaggerUrl,
57
+ openapiVer,
58
+ updated,
59
+ });
60
+ }
61
+
62
+ return entries;
63
+ }
64
+
65
+ export interface FilterOptions {
66
+ search?: string;
67
+ limit?: number;
68
+ noAuthOnly?: boolean;
69
+ preferOpenapi3?: boolean;
70
+ }
71
+
72
+ /**
73
+ * Filter and sort ApisGuruEntry array.
74
+ * - search: substring match (case-insensitive) on providerName or title
75
+ * - preferOpenapi3: sort 3.x entries before 2.x, then by recency within groups
76
+ * - default sort: by recency (updated desc)
77
+ * - limit: cap result count
78
+ */
79
+ export function filterEntries(
80
+ entries: ApisGuruEntry[],
81
+ options: FilterOptions,
82
+ ): ApisGuruEntry[] {
83
+ const { search, limit, preferOpenapi3 } = options;
84
+
85
+ let result = entries;
86
+
87
+ // Filter by search term
88
+ if (search) {
89
+ const lower = search.toLowerCase();
90
+ result = result.filter(
91
+ e =>
92
+ e.providerName.toLowerCase().includes(lower) ||
93
+ e.title.toLowerCase().includes(lower),
94
+ );
95
+ }
96
+
97
+ // Sort
98
+ result = [...result].sort((a, b) => {
99
+ if (preferOpenapi3) {
100
+ const aIs3 = a.openapiVer.startsWith('3') ? 0 : 1;
101
+ const bIs3 = b.openapiVer.startsWith('3') ? 0 : 1;
102
+ if (aIs3 !== bIs3) return aIs3 - bIs3;
103
+ }
104
+ // Within same group (or when not preferring 3.x), sort by recency desc
105
+ return b.updated.localeCompare(a.updated);
106
+ });
107
+
108
+ // Apply limit
109
+ if (typeof limit === 'number' && limit > 0) {
110
+ result = result.slice(0, limit);
111
+ }
112
+
113
+ return result;
114
+ }
115
+
116
+ /**
117
+ * Fetch the APIs.guru list.json and parse it into ApisGuruEntry array.
118
+ */
119
+ export async function fetchApisGuruList(): Promise<ApisGuruEntry[]> {
120
+ const ssrf = await resolveAndValidateUrl(APIS_GURU_LIST_URL);
121
+ if (!ssrf.safe) {
122
+ throw new Error(`SSRF check failed for APIs.guru list URL: ${ssrf.reason}`);
123
+ }
124
+ const response = await fetch(APIS_GURU_LIST_URL, {
125
+ signal: AbortSignal.timeout(30_000),
126
+ });
127
+ if (!response.ok) {
128
+ throw new Error(
129
+ `Failed to fetch APIs.guru list: ${response.status} ${response.statusText}`,
130
+ );
131
+ }
132
+ const text = await response.text();
133
+ try {
134
+ return parseApisGuruList(JSON.parse(text) as Record<string, any>);
135
+ } catch {
136
+ throw new Error(`Invalid JSON from ${APIS_GURU_LIST_URL}: ${text.slice(0, 100)}`);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Fetch a single OpenAPI spec by URL and return the parsed JSON.
142
+ */
143
+ export async function fetchSpec(specUrl: string): Promise<Record<string, any>> {
144
+ const ssrf = await resolveAndValidateUrl(specUrl);
145
+ if (!ssrf.safe) {
146
+ throw new Error(`SSRF check failed for spec URL ${specUrl}: ${ssrf.reason}`);
147
+ }
148
+ const response = await fetch(specUrl, {
149
+ signal: AbortSignal.timeout(30_000),
150
+ redirect: 'error',
151
+ });
152
+ if (!response.ok) {
153
+ throw new Error(
154
+ `Failed to fetch spec at ${specUrl}: ${response.status} ${response.statusText}`,
155
+ );
156
+ }
157
+ const text = await response.text();
158
+ try {
159
+ return JSON.parse(text) as Record<string, any>;
160
+ } catch {
161
+ throw new Error(`Invalid JSON from ${specUrl}: ${text.slice(0, 100)}`);
162
+ }
163
+ }
@@ -46,6 +46,18 @@ const STRIP_HEADERS = new Set([
46
46
  'cookie',
47
47
  ]);
48
48
 
49
+ /** Headers that should never be treated as auth by entropy detection. */
50
+ const NOT_AUTH_HEADERS = new Set([
51
+ 'referer', 'user-agent', 'content-type', 'accept', 'accept-language',
52
+ 'origin', 'host', 'content-length', 'cache-control', 'pragma', 'if-none-match',
53
+ 'if-modified-since', 'dnt', 'upgrade-insecure-requests',
54
+ // Observability/tracing (high entropy but not auth)
55
+ 'traceparent', 'tracestate', 'tracecontext', 'newrelic', 'sentry-trace',
56
+ 'baggage', 'x-request-id', 'x-correlation-id', 'x-trace-id', 'x-span-id',
57
+ 'x-datadog-trace-id', 'x-datadog-parent-id', 'x-datadog-sampling-priority',
58
+ 'x-amzn-trace-id', 'x-cloud-trace-context',
59
+ ]);
60
+
49
61
  const AUTH_HEADERS = new Set([
50
62
  'authorization',
51
63
  'x-api-key',
@@ -107,7 +119,8 @@ function extractAuth(headers: Record<string, string>): [StoredAuth[], Set<string
107
119
  });
108
120
  } else if (lower === 'x-api-key' && value) {
109
121
  auth.push({ type: 'api-key', header: lower, value });
110
- } else if (!AUTH_HEADERS.has(lower) && value) {
122
+ } else if (!AUTH_HEADERS.has(lower) && !STRIP_HEADERS.has(lower) && !NOT_AUTH_HEADERS.has(lower)
123
+ && !lower.startsWith('sec-') && value) {
111
124
  // Entropy-based detection for non-standard headers
112
125
  const classification = isLikelyToken(lower, value);
113
126
  if (classification.isToken) {
@@ -119,6 +132,25 @@ function extractAuth(headers: Record<string, string>): [StoredAuth[], Set<string
119
132
  return [auth, entropyDetected];
120
133
  }
121
134
 
135
+ /**
136
+ * Deduplicate extracted auth entries by header name and build a StoredAuth
137
+ * object with all unique headers. Entries are expected to be pre-sorted
138
+ * by priority (bearer > api-key > custom) via getExtractedAuth().
139
+ */
140
+ export function deduplicateAuth(extractedAuth: StoredAuth[]): StoredAuth | null {
141
+ if (extractedAuth.length === 0) return null;
142
+ const seen = new Set<string>();
143
+ const headers: Array<{ header: string; value: string }> = [];
144
+ for (const a of extractedAuth) {
145
+ if (!seen.has(a.header)) {
146
+ seen.add(a.header);
147
+ headers.push({ header: a.header, value: a.value });
148
+ }
149
+ }
150
+ const primary = extractedAuth[0];
151
+ return { type: primary.type, header: primary.header, value: primary.value, headers };
152
+ }
153
+
122
154
  function generateEndpointId(method: string, parameterizedPath: string): string {
123
155
  // Clean framework noise for the ID (but not for the stored path)
124
156
  let cleaned = cleanFrameworkPath(parameterizedPath);
@@ -476,9 +508,12 @@ export class SkillGenerator {
476
508
  this.filteredCount++;
477
509
  }
478
510
 
479
- /** Get auth credentials extracted during capture. */
511
+ /** Get auth credentials extracted during capture, prioritized by type. */
480
512
  getExtractedAuth(): StoredAuth[] {
481
- return this.extractedAuthList;
513
+ const priority: Record<string, number> = { bearer: 0, 'api-key': 1, custom: 2 };
514
+ return [...this.extractedAuthList].sort(
515
+ (a, b) => (priority[a.type] ?? 3) - (priority[b.type] ?? 3),
516
+ );
482
517
  }
483
518
 
484
519
  /** Mark this domain as having captcha risk (detected during capture). */
@@ -0,0 +1,281 @@
1
+ // src/skill/merge.ts
2
+ import type { SkillFile, SkillEndpoint, ImportMeta, MergeResult } from '../types.js';
3
+
4
+ /**
5
+ * Normalize a parameterized path by replacing all named param placeholders
6
+ * with the generic `:_` placeholder, enabling matching across different param
7
+ * naming conventions (e.g. `:id` vs `:userId` vs `:user_id`).
8
+ *
9
+ * @example
10
+ * normalizePath('/repos/:owner/:repo') // → '/repos/:_/:_'
11
+ * normalizePath('/users/list') // → '/users/list'
12
+ */
13
+ export function normalizePath(path: string): string {
14
+ return path.replace(/:[a-zA-Z_]\w*/g, ':_');
15
+ }
16
+
17
+ /**
18
+ * Build a match key for endpoint deduplication: METHOD + normalized path.
19
+ */
20
+ function matchKey(method: string, path: string): string {
21
+ return `${method.toUpperCase()} ${normalizePath(path)}`;
22
+ }
23
+
24
+ /**
25
+ * Merge query params from a captured endpoint with params from an imported
26
+ * spec endpoint.
27
+ *
28
+ * Rules:
29
+ * - Captured `example` values are preserved (captured data is sacred).
30
+ * - Spec `type`, `required`, `enum` augment the captured params.
31
+ * - New params that only exist in the spec are added wholesale.
32
+ */
33
+ function mergeQueryParams(
34
+ captured: SkillEndpoint['queryParams'],
35
+ specParams: SkillEndpoint['queryParams'],
36
+ ): SkillEndpoint['queryParams'] {
37
+ const merged = { ...captured };
38
+
39
+ for (const [name, specParam] of Object.entries(specParams)) {
40
+ if (name in merged) {
41
+ // Param exists in captured — keep captured example, augment with spec metadata
42
+ const existing = merged[name];
43
+ merged[name] = {
44
+ ...existing,
45
+ // Take spec type only if captured has generic 'string' and spec is more specific
46
+ type: existing.type === 'string' && specParam.type !== 'string' ? specParam.type : existing.type,
47
+ // Always preserve captured example value
48
+ example: existing.example,
49
+ // Add spec enum if present
50
+ ...(specParam.enum !== undefined ? { enum: specParam.enum } : {}),
51
+ // Add spec required flag if present
52
+ ...(specParam.required !== undefined ? { required: specParam.required } : {}),
53
+ // Mark as also coming from spec
54
+ ...(specParam.fromSpec ? { fromSpec: true } : {}),
55
+ };
56
+ } else {
57
+ // New param only in spec — add it
58
+ merged[name] = specParam;
59
+ }
60
+ }
61
+
62
+ return merged;
63
+ }
64
+
65
+ /**
66
+ * Determine whether an imported endpoint would enrich an existing captured
67
+ * endpoint (i.e. adds new metadata not already present).
68
+ */
69
+ function wouldEnrich(existing: SkillEndpoint, imported: SkillEndpoint): boolean {
70
+ if (!existing.description && imported.description) return true;
71
+ if (!existing.specSource && imported.specSource) return true;
72
+
73
+ // Check if any new query params would be added or enriched
74
+ for (const [name, specParam] of Object.entries(imported.queryParams)) {
75
+ if (!(name in existing.queryParams)) return true;
76
+ const ep = existing.queryParams[name];
77
+ if (!ep.enum && specParam.enum) return true;
78
+ if (ep.required === undefined && specParam.required !== undefined) return true;
79
+ if (ep.type === 'string' && specParam.type !== 'string') return true;
80
+ }
81
+
82
+ return false;
83
+ }
84
+
85
+ /**
86
+ * Pure function — no I/O.
87
+ *
88
+ * Merges imported OpenAPI endpoints into an existing skill file.
89
+ *
90
+ * Captured data is sacred: it always wins on confidence, examples, and
91
+ * endpoint provenance. Spec data can only enrich (add description, specSource,
92
+ * query param enum/required/type) or fill gaps (add missing endpoints).
93
+ *
94
+ * Match logic: METHOD + normalizePath(path). This allows `:owner` and `:user`
95
+ * to be considered the same parameter slot.
96
+ *
97
+ * @param existing The existing skill file on disk, or null if none exists.
98
+ * @param imported Endpoints parsed from the OpenAPI spec.
99
+ * @param importMeta Metadata about the import (spec URL, version, etc.).
100
+ * @returns MergeResult with the updated SkillFile and a diff summary.
101
+ */
102
+ export function mergeSkillFile(
103
+ existing: SkillFile | null,
104
+ imported: SkillEndpoint[],
105
+ importMeta: ImportMeta,
106
+ ): MergeResult {
107
+ const now = new Date().toISOString();
108
+
109
+ let preserved = 0;
110
+ let added = 0;
111
+ let enriched = 0;
112
+ let skipped = 0;
113
+
114
+ // --- Case: no existing file — create a new SkillFile from imported endpoints ---
115
+ if (existing === null) {
116
+ const endpoints = imported.map(ep => ({
117
+ ...ep,
118
+ normalizedPath: normalizePath(ep.path),
119
+ }));
120
+
121
+ const skillFile: SkillFile = {
122
+ version: '1.2',
123
+ domain: extractDomainFromMeta(importMeta),
124
+ capturedAt: now,
125
+ baseUrl: extractBaseUrlFromMeta(importMeta),
126
+ endpoints,
127
+ metadata: {
128
+ captureCount: 0,
129
+ filteredCount: 0,
130
+ toolVersion: '1.0.0',
131
+ importHistory: [{
132
+ specUrl: importMeta.specUrl,
133
+ specVersion: importMeta.specVersion,
134
+ importedAt: now,
135
+ endpointsAdded: endpoints.length,
136
+ endpointsEnriched: 0,
137
+ }],
138
+ },
139
+ provenance: 'imported',
140
+ };
141
+
142
+ added = endpoints.length;
143
+
144
+ return {
145
+ skillFile,
146
+ diff: { preserved, added, enriched, skipped },
147
+ };
148
+ }
149
+
150
+ // --- Case: existing file present — merge into it ---
151
+
152
+ // Build a map from match-key → existing endpoint (mutable copy)
153
+ const existingMap = new Map<string, SkillEndpoint>();
154
+ for (const ep of existing.endpoints) {
155
+ existingMap.set(matchKey(ep.method, ep.path), ep);
156
+ }
157
+
158
+ // Build a map from match-key → imported endpoint
159
+ const importedMap = new Map<string, SkillEndpoint>();
160
+ for (const ep of imported) {
161
+ const key = matchKey(ep.method, ep.path);
162
+ // If multiple imported endpoints map to the same key, last wins
163
+ if (importedMap.has(key)) {
164
+ process.stderr.write(`[openapi-import] Warning: ${ep.method} ${ep.path} collides with existing import after normalization\n`);
165
+ }
166
+ importedMap.set(key, ep);
167
+ }
168
+
169
+ // Process: update or preserve existing endpoints
170
+ const resultEndpoints: SkillEndpoint[] = [];
171
+
172
+ for (const [key, existingEp] of existingMap) {
173
+ const importedEp = importedMap.get(key);
174
+
175
+ if (!importedEp) {
176
+ // Not in import — preserve as-is (captured endpoint with no match in spec)
177
+ resultEndpoints.push({
178
+ ...existingEp,
179
+ normalizedPath: normalizePath(existingEp.path),
180
+ });
181
+ preserved++;
182
+ continue;
183
+ }
184
+
185
+ // Imported endpoint matches an existing one — check if it would add anything new
186
+ if (!wouldEnrich(existingEp, importedEp)) {
187
+ resultEndpoints.push({
188
+ ...existingEp,
189
+ normalizedPath: normalizePath(existingEp.path),
190
+ });
191
+ // "skipped" means the import is redundant — existing already has spec metadata.
192
+ // "preserved" means the captured endpoint is untouched (import had nothing for it,
193
+ // or both sides are bare with no spec data to exchange).
194
+ const existingHasSpecData = !!(existingEp.specSource || existingEp.description);
195
+ const importHasSpecData = !!(importedEp.specSource || importedEp.description);
196
+ if (existingHasSpecData || importHasSpecData) {
197
+ // Spec data was already integrated (or import tried to add it but it's already present)
198
+ skipped++;
199
+ } else {
200
+ // Neither side has spec enrichment data — captured endpoint simply preserved
201
+ preserved++;
202
+ }
203
+ continue;
204
+ }
205
+
206
+ // Enrich the captured endpoint with spec metadata
207
+ const mergedQueryParams = mergeQueryParams(existingEp.queryParams, importedEp.queryParams);
208
+
209
+ const enrichedEp: SkillEndpoint = {
210
+ ...existingEp,
211
+ normalizedPath: normalizePath(existingEp.path),
212
+ // Augment with spec fields (only if not already present)
213
+ ...(importedEp.description && !existingEp.description ? { description: importedEp.description } : {}),
214
+ ...(importedEp.specSource && !existingEp.specSource ? { specSource: importedEp.specSource } : {}),
215
+ // Confidence never downgrades
216
+ confidence: Math.max(existingEp.confidence ?? 0, importedEp.confidence ?? 0) || existingEp.confidence,
217
+ // Keep captured provenance
218
+ endpointProvenance: existingEp.endpointProvenance,
219
+ queryParams: mergedQueryParams,
220
+ };
221
+
222
+ resultEndpoints.push(enrichedEp);
223
+ enriched++;
224
+ }
225
+
226
+ // Add endpoints from import that don't exist in the existing file
227
+ for (const [key, importedEp] of importedMap) {
228
+ if (!existingMap.has(key)) {
229
+ resultEndpoints.push({
230
+ ...importedEp,
231
+ normalizedPath: normalizePath(importedEp.path),
232
+ });
233
+ added++;
234
+ }
235
+ }
236
+
237
+ // Build updated import history
238
+ const prevHistory = existing.metadata.importHistory ?? [];
239
+ const newHistoryEntry = {
240
+ specUrl: importMeta.specUrl,
241
+ specVersion: importMeta.specVersion,
242
+ importedAt: now,
243
+ endpointsAdded: added,
244
+ endpointsEnriched: enriched,
245
+ };
246
+
247
+ const skillFile: SkillFile = {
248
+ ...existing,
249
+ endpoints: resultEndpoints,
250
+ metadata: {
251
+ ...existing.metadata,
252
+ importHistory: [...prevHistory, newHistoryEntry],
253
+ },
254
+ };
255
+
256
+ return {
257
+ skillFile,
258
+ diff: { preserved, added, enriched, skipped },
259
+ };
260
+ }
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // Internal helpers
264
+ // ---------------------------------------------------------------------------
265
+
266
+ function extractDomainFromMeta(meta: ImportMeta): string {
267
+ try {
268
+ return new URL(meta.specUrl).hostname;
269
+ } catch {
270
+ throw new Error(`Cannot determine domain from specUrl: ${meta.specUrl}`);
271
+ }
272
+ }
273
+
274
+ function extractBaseUrlFromMeta(meta: ImportMeta): string {
275
+ try {
276
+ const u = new URL(meta.specUrl);
277
+ return `${u.protocol}//${u.hostname}`;
278
+ } catch {
279
+ throw new Error(`Cannot determine base URL from specUrl: ${meta.specUrl}`);
280
+ }
281
+ }