@apitap/core 1.6.0 → 1.6.2

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.
@@ -45,6 +45,15 @@ const BLOCKED_REPLAY_HEADERS = new Set([
45
45
  'sec-fetch-user',
46
46
  ]);
47
47
 
48
+ export function safeParseJson(text: string): unknown {
49
+ if (text.length === 0) return text;
50
+ try {
51
+ return JSON.parse(text);
52
+ } catch {
53
+ return text;
54
+ }
55
+ }
56
+
48
57
  export interface ReplayOptions {
49
58
  /** User-provided parameters for path, query, and body substitution */
50
59
  params?: Record<string, string>;
@@ -70,6 +79,8 @@ export interface ReplayResult {
70
79
  truncated?: boolean;
71
80
  /** Contract warnings from schema drift detection */
72
81
  contractWarnings?: ContractWarning[];
82
+ /** Upgrade hint: set when a low-confidence endpoint gets a 2xx response */
83
+ upgrade?: { confidence: 1.0; endpointProvenance: 'captured' };
73
84
  }
74
85
 
75
86
  /**
@@ -207,9 +218,15 @@ function wrapAuthError(
207
218
  /**
208
219
  * Returns a user-facing hint about confidence level, or null if confidence is high enough.
209
220
  */
210
- export function getConfidenceHint(confidence: number | undefined): string | null {
221
+ export function getConfidenceHint(
222
+ confidence: number | undefined,
223
+ provenance?: string,
224
+ ): string | null {
211
225
  const c = confidence ?? 1.0;
212
226
  if (c >= 0.85) return null;
227
+ if (provenance === 'skeleton') {
228
+ return '(observed in traffic, no response shape — first successful replay will upgrade)';
229
+ }
213
230
  if (c >= 0.7) return '(imported from spec — params may need adjustment)';
214
231
  return '(imported from spec — provide params explicitly, no captured examples available)';
215
232
  }
@@ -548,8 +565,8 @@ export async function replayEndpoint(
548
565
  let retryData: unknown;
549
566
  const retryCt = retryResponse.headers.get('content-type') ?? '';
550
567
  const retryText = await retryResponse.text();
551
- if (retryCt.includes('json') && retryText.length > 0) {
552
- retryData = JSON.parse(retryText);
568
+ if (retryCt.includes('json')) {
569
+ retryData = safeParseJson(retryText);
553
570
  } else {
554
571
  retryData = retryText;
555
572
  }
@@ -558,6 +575,11 @@ export async function replayEndpoint(
558
575
  ? wrapAuthError(retryResponse.status, retryData, skill.domain)
559
576
  : retryData;
560
577
 
578
+ const retryUpgrade = (endpoint.confidence !== undefined && endpoint.confidence < 1.0
579
+ && retryResponse.status >= 200 && retryResponse.status < 300)
580
+ ? { confidence: 1.0 as const, endpointProvenance: 'captured' as const }
581
+ : undefined;
582
+
561
583
  if (options.maxBytes) {
562
584
  const truncated = truncateResponse(retryFinalData, { maxBytes: options.maxBytes });
563
585
  return {
@@ -566,6 +588,7 @@ export async function replayEndpoint(
566
588
  data: truncated.data,
567
589
  refreshed,
568
590
  ...(truncated.truncated ? { truncated: true } : {}),
591
+ ...(retryUpgrade ? { upgrade: retryUpgrade } : {}),
569
592
  };
570
593
  }
571
594
 
@@ -574,6 +597,7 @@ export async function replayEndpoint(
574
597
  headers: retryHeaders,
575
598
  data: retryFinalData,
576
599
  refreshed,
600
+ ...(retryUpgrade ? { upgrade: retryUpgrade } : {}),
577
601
  };
578
602
  }
579
603
  }
@@ -586,8 +610,8 @@ export async function replayEndpoint(
586
610
  let data: unknown;
587
611
  const ct = response.headers.get('content-type') ?? '';
588
612
  const text = await response.text();
589
- if (ct.includes('json') && text.length > 0) {
590
- data = JSON.parse(text);
613
+ if (ct.includes('json')) {
614
+ data = safeParseJson(text);
591
615
  } else {
592
616
  data = text;
593
617
  }
@@ -606,6 +630,11 @@ export async function replayEndpoint(
606
630
  }
607
631
  }
608
632
 
633
+ const upgrade = (endpoint.confidence !== undefined && endpoint.confidence < 1.0
634
+ && response.status >= 200 && response.status < 300)
635
+ ? { confidence: 1.0 as const, endpointProvenance: 'captured' as const }
636
+ : undefined;
637
+
609
638
  // Apply truncation if maxBytes is set
610
639
  if (options.maxBytes) {
611
640
  const truncated = truncateResponse(finalData, { maxBytes: options.maxBytes });
@@ -616,10 +645,11 @@ export async function replayEndpoint(
616
645
  ...(refreshed ? { refreshed } : {}),
617
646
  ...(truncated.truncated ? { truncated: true } : {}),
618
647
  ...(contractWarnings ? { contractWarnings } : {}),
648
+ ...(upgrade ? { upgrade } : {}),
619
649
  };
620
650
  }
621
651
 
622
- return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}), ...(contractWarnings ? { contractWarnings } : {}) };
652
+ return { status: response.status, headers: responseHeaders, data: finalData, ...(refreshed ? { refreshed } : {}), ...(contractWarnings ? { contractWarnings } : {}), ...(upgrade ? { upgrade } : {}) };
623
653
  }
624
654
 
625
655
  // --- Batch replay ---
@@ -1,6 +1,29 @@
1
1
  // src/skill/apis-guru.ts
2
2
  import { resolveAndValidateUrl } from '../skill/ssrf.js';
3
3
 
4
+ const MAX_SPEC_SIZE = 10 * 1024 * 1024; // 10 MB per spec
5
+ const MAX_LIST_SIZE = 100 * 1024 * 1024; // 100 MB for APIs.guru list
6
+
7
+ async function fetchWithSizeLimit(url: string, maxBytes: number, options?: RequestInit): Promise<string> {
8
+ const response = await fetch(url, {
9
+ signal: AbortSignal.timeout(30_000),
10
+ ...options,
11
+ headers: { 'User-Agent': 'apitap-import/1.0', ...(options?.headers as Record<string, string> || {}) },
12
+ });
13
+ if (!response.ok) {
14
+ throw new Error(`HTTP ${response.status} ${response.statusText} for ${url}`);
15
+ }
16
+ const contentLength = response.headers.get('content-length');
17
+ if (contentLength && parseInt(contentLength, 10) > maxBytes) {
18
+ throw new Error(`Response too large: ${contentLength} bytes (limit: ${maxBytes})`);
19
+ }
20
+ const text = await response.text();
21
+ if (text.length > maxBytes) {
22
+ throw new Error(`Response body too large: ${text.length} bytes (limit: ${maxBytes})`);
23
+ }
24
+ return text;
25
+ }
26
+
4
27
  export interface ApisGuruEntry {
5
28
  apiId: string; // e.g., "twilio.com:api"
6
29
  providerName: string; // e.g., "twilio.com"
@@ -121,15 +144,7 @@ export async function fetchApisGuruList(): Promise<ApisGuruEntry[]> {
121
144
  if (!ssrf.safe) {
122
145
  throw new Error(`SSRF check failed for APIs.guru list URL: ${ssrf.reason}`);
123
146
  }
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();
147
+ const text = await fetchWithSizeLimit(APIS_GURU_LIST_URL, MAX_LIST_SIZE);
133
148
  try {
134
149
  return parseApisGuruList(JSON.parse(text) as Record<string, any>);
135
150
  } catch {
@@ -145,16 +160,7 @@ export async function fetchSpec(specUrl: string): Promise<Record<string, any>> {
145
160
  if (!ssrf.safe) {
146
161
  throw new Error(`SSRF check failed for spec URL ${specUrl}: ${ssrf.reason}`);
147
162
  }
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();
163
+ const text = await fetchWithSizeLimit(specUrl, MAX_SPEC_SIZE, { redirect: 'error' });
158
164
  try {
159
165
  return JSON.parse(text) as Record<string, any>;
160
166
  } catch {
@@ -292,6 +292,7 @@ export class SkillGenerator {
292
292
  private oauthClientSecret: string | undefined;
293
293
  private oauthRefreshToken: string | undefined;
294
294
  private totalNetworkBytes = 0; // v1.0: accumulate all response sizes
295
+ private _lastDedupKey: string | null = null; // side-channel for dedup key when _prepareEndpoint returns null
295
296
 
296
297
  /** Number of unique endpoints captured so far */
297
298
  get endpointCount(): number {
@@ -305,13 +306,37 @@ export class SkillGenerator {
305
306
  };
306
307
  }
307
308
 
308
- /** Add a captured exchange. Returns the new endpoint if first seen, null if duplicate. */
309
- addExchange(exchange: CapturedExchange): SkillEndpoint | null {
309
+ /**
310
+ * Extract shared request-side logic: URL parsing, GraphQL detection, path
311
+ * parameterization, dedup, OAuth, auth extraction, header/query scrubbing,
312
+ * and endpoint ID generation.
313
+ *
314
+ * Returns null when the endpoint is a duplicate (existing non-skeleton).
315
+ * When returning null, stores the dedup key in `_lastDedupKey` so callers
316
+ * can still perform body storage for cross-request diffing.
317
+ */
318
+ private _prepareEndpoint(request: {
319
+ url: string;
320
+ method: string;
321
+ headers: Record<string, string>;
322
+ postData?: string;
323
+ }): {
324
+ key: string;
325
+ method: string;
326
+ paramPath: string;
327
+ queryParams: Record<string, { type: string; example: string }>;
328
+ safeHeaders: Record<string, string>;
329
+ exampleUrl: string;
330
+ endpointId: string;
331
+ graphqlInfo: { operationName: string; query: string; variables: Record<string, unknown> | null } | null;
332
+ entropyDetected: Set<string>;
333
+ isSkeletonReplacement: boolean;
334
+ } | null {
310
335
  this.captureCount++;
311
336
 
312
- const url = new URL(exchange.request.url);
313
- const method = exchange.request.method;
314
- const contentType = exchange.request.headers['content-type'] ?? '';
337
+ const url = new URL(request.url);
338
+ const method = request.method;
339
+ const contentType = request.headers['content-type'] ?? '';
315
340
 
316
341
  // Track baseUrl from the first captured exchange
317
342
  if (!this.baseUrl) {
@@ -319,11 +344,11 @@ export class SkillGenerator {
319
344
  }
320
345
 
321
346
  // Check for GraphQL
322
- const isGraphQL = isGraphQLEndpoint(url.pathname, contentType, exchange.request.postData ?? null);
347
+ const isGraphQL = isGraphQLEndpoint(url.pathname, contentType, request.postData ?? null);
323
348
  let graphqlInfo: { operationName: string; query: string; variables: Record<string, unknown> | null } | null = null;
324
349
 
325
- if (isGraphQL && exchange.request.postData) {
326
- const parsed = parseGraphQLBody(exchange.request.postData);
350
+ if (isGraphQL && request.postData) {
351
+ const parsed = parseGraphQLBody(request.postData);
327
352
  if (parsed) {
328
353
  const opName = extractOperationName(parsed.query, parsed.operationName);
329
354
  graphqlInfo = {
@@ -344,20 +369,22 @@ export class SkillGenerator {
344
369
  ? `${method} graphql:${graphqlInfo.operationName}`
345
370
  : `${method} ${dedupPath}`;
346
371
 
347
- // Track response bytes for all exchanges (for browser cost measurement)
348
- this.totalNetworkBytes += exchange.response.body.length;
349
-
350
372
  if (this.endpoints.has(key)) {
351
- // Store duplicate body for cross-request diffing (Strategy 1) — scrubbed (H3 fix)
352
- if (exchange.request.postData) {
353
- const bodies = this.exchangeBodies.get(key);
354
- if (bodies) bodies.push(this.scrubBodyString(exchange.request.postData));
373
+ const existing = this.endpoints.get(key)!;
374
+ if (existing.endpointProvenance === 'skeleton') {
375
+ // Skeleton endpoint — fall through to replace it with captured data
376
+ } else {
377
+ // Non-skeleton duplicate — store key for body diffing, return null
378
+ this._lastDedupKey = key;
379
+ return null;
355
380
  }
356
- return null;
357
381
  }
358
382
 
383
+ const isSkeletonReplacement = this.endpoints.has(key)
384
+ && this.endpoints.get(key)!.endpointProvenance === 'skeleton';
385
+
359
386
  // Detect OAuth token requests from captured traffic
360
- const oauthInfo = isOAuthTokenRequest(exchange.request);
387
+ const oauthInfo = isOAuthTokenRequest(request);
361
388
  if (oauthInfo && !this.oauthConfig) {
362
389
  this.oauthConfig = {
363
390
  tokenEndpoint: oauthInfo.tokenEndpoint,
@@ -370,11 +397,11 @@ export class SkillGenerator {
370
397
  }
371
398
 
372
399
  // Extract auth before filtering headers (includes entropy-based detection)
373
- const [auth, entropyDetected] = extractAuth(exchange.request.headers);
400
+ const [auth, entropyDetected] = extractAuth(request.headers);
374
401
  this.extractedAuthList.push(...auth);
375
402
 
376
403
  // Filter headers, then strip auth values (including entropy-detected tokens)
377
- const filtered = filterHeaders(exchange.request.headers);
404
+ const filtered = filterHeaders(request.headers);
378
405
  const safeHeaders = stripAuth(filtered, entropyDetected);
379
406
 
380
407
  // Build query params, optionally scrub PII
@@ -384,11 +411,50 @@ export class SkillGenerator {
384
411
  }
385
412
 
386
413
  // Build example URL, optionally scrub PII and sensitive query params
387
- let exampleUrl = exchange.request.url;
414
+ let exampleUrl = request.url;
388
415
  if (this.options.scrub) {
389
416
  exampleUrl = scrubUrlQueryParams(scrubPII(exampleUrl));
390
417
  }
391
418
 
419
+ // Generate endpoint ID - use GraphQL operation name if applicable
420
+ const endpointId = graphqlInfo
421
+ ? `${method.toLowerCase()}-graphql-${graphqlInfo.operationName}`
422
+ : generateEndpointId(method, paramPath);
423
+
424
+ return {
425
+ key,
426
+ method,
427
+ paramPath,
428
+ queryParams,
429
+ safeHeaders,
430
+ exampleUrl,
431
+ endpointId,
432
+ graphqlInfo,
433
+ entropyDetected,
434
+ isSkeletonReplacement,
435
+ };
436
+ }
437
+
438
+ /** Add a captured exchange. Returns the new endpoint if first seen, null if duplicate. */
439
+ addExchange(exchange: CapturedExchange): SkillEndpoint | null {
440
+ // Track response bytes for all exchanges (for browser cost measurement)
441
+ this.totalNetworkBytes += exchange.response.body.length;
442
+
443
+ const prep = this._prepareEndpoint(exchange.request);
444
+
445
+ if (!prep) {
446
+ // Dedup: store body for cross-request diffing (Strategy 1) — scrubbed (H3 fix)
447
+ const dedupKey = this._lastDedupKey;
448
+ if (dedupKey && exchange.request.postData) {
449
+ const bodies = this.exchangeBodies.get(dedupKey);
450
+ if (bodies) bodies.push(this.scrubBodyString(exchange.request.postData));
451
+ }
452
+ return null;
453
+ }
454
+
455
+ const { key, method, paramPath, queryParams, safeHeaders, exampleUrl,
456
+ endpointId, graphqlInfo, entropyDetected } = prep;
457
+
392
458
  // Response preview: null by default, populated with --preview
393
459
  let responsePreview: unknown = null;
394
460
  if (this.options.enablePreview) {
@@ -445,11 +511,6 @@ export class SkillGenerator {
445
511
  }
446
512
  }
447
513
 
448
- // Generate endpoint ID - use GraphQL operation name if applicable
449
- const endpointId = graphqlInfo
450
- ? `${method.toLowerCase()}-graphql-${graphqlInfo.operationName}`
451
- : generateEndpointId(method, paramPath);
452
-
453
514
  const endpoint: SkillEndpoint = {
454
515
  id: endpointId,
455
516
  method: exchange.request.method,
@@ -503,6 +564,52 @@ export class SkillGenerator {
503
564
  return endpoint;
504
565
  }
505
566
 
567
+ /** Add a skeleton endpoint (request data only, no response body). Confidence 0.8. */
568
+ addSkeleton(request: {
569
+ url: string;
570
+ method: string;
571
+ headers: Record<string, string>;
572
+ postData?: string;
573
+ }): SkillEndpoint | null {
574
+ const prep = this._prepareEndpoint(request);
575
+ if (!prep) return null;
576
+
577
+ const { key, paramPath, queryParams, safeHeaders, exampleUrl,
578
+ endpointId, entropyDetected, isSkeletonReplacement } = prep;
579
+
580
+ // If a skeleton already occupies this key, don't overwrite with another skeleton
581
+ if (isSkeletonReplacement) return null;
582
+
583
+ const endpoint: SkillEndpoint = {
584
+ id: endpointId,
585
+ method: request.method,
586
+ path: paramPath,
587
+ queryParams,
588
+ headers: safeHeaders,
589
+ responseShape: { type: 'unknown' },
590
+ examples: {
591
+ request: {
592
+ url: exampleUrl,
593
+ headers: stripAuth(filterHeaders(request.headers)),
594
+ },
595
+ responsePreview: null,
596
+ },
597
+ confidence: 0.8,
598
+ endpointProvenance: 'skeleton',
599
+ responseBytes: 0,
600
+ };
601
+
602
+ // Also strip entropy-detected tokens from example headers
603
+ if (entropyDetected.size > 0) {
604
+ endpoint.examples.request.headers = stripAuth(
605
+ filterHeaders(request.headers), entropyDetected
606
+ );
607
+ }
608
+
609
+ this.endpoints.set(key, endpoint);
610
+ return endpoint;
611
+ }
612
+
506
613
  /** Record a filtered-out request (for metadata tracking). */
507
614
  recordFiltered(): void {
508
615
  this.filteredCount++;
@@ -82,6 +82,33 @@ function wouldEnrich(existing: SkillEndpoint, imported: SkillEndpoint): boolean
82
82
  return false;
83
83
  }
84
84
 
85
+ /**
86
+ * Determine whether an imported endpoint would enrich an existing skeleton
87
+ * endpoint. Skeletons are enrichable by imports: imports can provide response
88
+ * shape (replacing the placeholder `{ type: 'unknown' }`), description,
89
+ * specSource, and query param metadata.
90
+ */
91
+ function wouldEnrichSkeleton(existing: SkillEndpoint, imported: SkillEndpoint): boolean {
92
+ // Import can replace an unknown response shape
93
+ const existingShape = existing.responseShape;
94
+ const isUnknownShape = existingShape.type === 'unknown' && !existingShape.fields;
95
+ if (isUnknownShape && imported.responseShape.type !== 'unknown') return true;
96
+
97
+ if (!existing.description && imported.description) return true;
98
+ if (!existing.specSource && imported.specSource) return true;
99
+
100
+ // Check if any new query params would be added or enriched
101
+ for (const [name, specParam] of Object.entries(imported.queryParams)) {
102
+ if (!(name in existing.queryParams)) return true;
103
+ const ep = existing.queryParams[name];
104
+ if (!ep.enum && specParam.enum) return true;
105
+ if (ep.required === undefined && specParam.required !== undefined) return true;
106
+ if (ep.type === 'string' && specParam.type !== 'string') return true;
107
+ }
108
+
109
+ return false;
110
+ }
111
+
85
112
  /**
86
113
  * Pure function — no I/O.
87
114
  *
@@ -182,6 +209,51 @@ export function mergeSkillFile(
182
209
  continue;
183
210
  }
184
211
 
212
+ // --- Skeleton branch: import can enrich skeleton endpoints ---
213
+ if (existingEp.endpointProvenance === 'skeleton') {
214
+ if (!wouldEnrichSkeleton(existingEp, importedEp)) {
215
+ resultEndpoints.push({
216
+ ...existingEp,
217
+ normalizedPath: normalizePath(existingEp.path),
218
+ });
219
+ const existingHasSpecData = !!(existingEp.specSource || existingEp.description);
220
+ const importHasSpecData = !!(importedEp.specSource || importedEp.description);
221
+ if (existingHasSpecData || importHasSpecData) {
222
+ skipped++;
223
+ } else {
224
+ preserved++;
225
+ }
226
+ continue;
227
+ }
228
+
229
+ const mergedQueryParams = mergeQueryParams(existingEp.queryParams, importedEp.queryParams);
230
+
231
+ // For skeletons, import can replace responseShape if existing is unknown
232
+ const existingShape = existingEp.responseShape;
233
+ const isUnknownShape = existingShape.type === 'unknown' && !existingShape.fields;
234
+ const responseShape = isUnknownShape && importedEp.responseShape.type !== 'unknown'
235
+ ? importedEp.responseShape
236
+ : existingShape;
237
+
238
+ const enrichedSkeleton: SkillEndpoint = {
239
+ ...existingEp,
240
+ normalizedPath: normalizePath(existingEp.path),
241
+ responseShape,
242
+ // Augment with spec fields (only if not already present)
243
+ ...(importedEp.description && !existingEp.description ? { description: importedEp.description } : {}),
244
+ ...(importedEp.specSource && !existingEp.specSource ? { specSource: importedEp.specSource } : {}),
245
+ // Confidence = max(skeleton, import)
246
+ confidence: Math.max(existingEp.confidence ?? 0, importedEp.confidence ?? 0) || existingEp.confidence,
247
+ // Provenance stays 'skeleton'
248
+ endpointProvenance: 'skeleton',
249
+ queryParams: mergedQueryParams,
250
+ };
251
+
252
+ resultEndpoints.push(enrichedSkeleton);
253
+ enriched++;
254
+ continue;
255
+ }
256
+
185
257
  // Imported endpoint matches an existing one — check if it would add anything new
186
258
  if (!wouldEnrich(existingEp, importedEp)) {
187
259
  resultEndpoints.push({
@@ -31,6 +31,14 @@ export function resolveRef(
31
31
  return merged;
32
32
  }
33
33
 
34
+ // Handle oneOf/anyOf: use first option as representative
35
+ if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) {
36
+ return resolveRef(obj.oneOf[0], spec, new Set(visited), depth + 1);
37
+ }
38
+ if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) {
39
+ return resolveRef(obj.anyOf[0], spec, new Set(visited), depth + 1);
40
+ }
41
+
34
42
  if (!obj.$ref) return obj;
35
43
 
36
44
  const ref = obj.$ref as string;
@@ -44,6 +44,20 @@ export function signSkillFile(skill: SkillFile, key: Buffer): SkillFile {
44
44
  };
45
45
  }
46
46
 
47
+ /**
48
+ * Sign a skill file with a specific provenance value.
49
+ * Use 'self' for captured files, 'imported-signed' for import-only files.
50
+ */
51
+ export function signSkillFileAs(
52
+ skill: SkillFile,
53
+ key: Buffer,
54
+ provenance: 'self' | 'imported-signed',
55
+ ): SkillFile {
56
+ const payload = canonicalize(skill);
57
+ const signature = hmacSign(payload, key);
58
+ return { ...skill, provenance, signature };
59
+ }
60
+
47
61
  /**
48
62
  * Verify a skill file's signature.
49
63
  * Returns true if the signature is valid for the given key.
@@ -1,5 +1,5 @@
1
1
  // src/skill/store.ts
2
- import { readFile, writeFile, mkdir, readdir, access } from 'node:fs/promises';
2
+ import { readFile, writeFile, mkdir, readdir, access, rename } from 'node:fs/promises';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import type { SkillFile, SkillSummary } from '../types.js';
@@ -40,7 +40,10 @@ export async function writeSkillFile(
40
40
  await mkdir(skillsDir, { recursive: true, mode: 0o700 });
41
41
  await ensureGitignore(skillsDir);
42
42
  const filePath = skillPath(skill.domain, skillsDir);
43
- await writeFile(filePath, JSON.stringify(skill, null, 2) + '\n', { mode: 0o600 });
43
+ const tmpPath = `${filePath}.${process.pid}.tmp`;
44
+ const content = JSON.stringify(skill, null, 2) + '\n';
45
+ await writeFile(tmpPath, content, { mode: 0o600 });
46
+ await rename(tmpPath, filePath);
44
47
  return filePath;
45
48
  }
46
49
 
@@ -114,8 +117,9 @@ export async function listSkillFiles(
114
117
  let files: string[];
115
118
  try {
116
119
  files = await readdir(skillsDir);
117
- } catch {
118
- return [];
120
+ } catch (err: unknown) {
121
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
122
+ throw err;
119
123
  }
120
124
 
121
125
  const summaries: SkillSummary[] = [];
package/src/types.ts CHANGED
@@ -133,7 +133,7 @@ export interface SkillEndpoint {
133
133
  responseSchema?: SchemaNode; // v1.1: schema snapshot for contract validation
134
134
  normalizedPath?: string;
135
135
  confidence?: number;
136
- endpointProvenance?: 'captured' | 'openapi-import';
136
+ endpointProvenance?: 'captured' | 'openapi-import' | 'skeleton';
137
137
  specSource?: string;
138
138
  description?: string;
139
139
  }
@@ -162,7 +162,7 @@ export interface SkillFile {
162
162
  endpointsEnriched: number;
163
163
  }>;
164
164
  };
165
- provenance: 'self' | 'imported' | 'unsigned';
165
+ provenance: 'self' | 'imported' | 'imported-signed' | 'unsigned';
166
166
  signature?: string;
167
167
  auth?: SkillAuth; // v0.8: top-level auth config
168
168
  }
@@ -215,7 +215,7 @@ export interface SkillSummary {
215
215
  skillFile: string;
216
216
  endpointCount: number;
217
217
  capturedAt: string;
218
- provenance: 'self' | 'imported' | 'unsigned';
218
+ provenance: 'self' | 'imported' | 'imported-signed' | 'unsigned';
219
219
  }
220
220
 
221
221
  // --- Discovery types (Milestone 2: Smart Discovery) ---