@apitap/core 1.5.4 → 1.6.1

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/src/cli.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  // src/cli.ts
3
3
  import { capture } from './capture/monitor.js';
4
4
  import { writeSkillFile, readSkillFile, listSkillFiles } from './skill/store.js';
5
- import { replayEndpoint } from './replay/engine.js';
5
+ import { replayEndpoint, getConfidenceHint } from './replay/engine.js';
6
6
  import { AuthManager, getMachineId } from './auth/manager.js';
7
7
  import { deriveSigningKey } from './auth/crypto.js';
8
- import { signSkillFile } from './skill/signing.js';
8
+ import { signSkillFile, signSkillFileAs } from './skill/signing.js';
9
9
  import { importSkillFile } from './skill/importer.js';
10
10
  import { resolveAndValidateUrl } from './skill/ssrf.js';
11
11
  import { verifyEndpoints } from './capture/verifier.js';
@@ -27,6 +27,9 @@ import { stat, unlink } from 'node:fs/promises';
27
27
  import { fileURLToPath } from 'node:url';
28
28
  import { createMcpServer } from './mcp.js';
29
29
  import { attach, parseDomainPatterns } from './capture/cdp-attach.js';
30
+ import { isOpenAPISpec, convertOpenAPISpec } from './skill/openapi-converter.js';
31
+ import { mergeSkillFile } from './skill/merge.js';
32
+ import { fetchApisGuruList, filterEntries, fetchSpec } from './skill/apis-guru.js';
30
33
 
31
34
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
32
35
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -76,7 +79,9 @@ function printUsage(): void {
76
79
  apitap show <domain> Show endpoints for a domain
77
80
  apitap replay <domain> <endpoint-id> [key=value...]
78
81
  Replay an API endpoint
79
- apitap import <file> Import a skill file with safety validation
82
+ apitap import <file|url> Import a skill file or OpenAPI spec
83
+ apitap import --from apis-guru
84
+ Bulk-import from APIs.guru directory
80
85
  apitap refresh <domain> Refresh auth tokens via browser
81
86
  apitap auth [domain] View or manage stored auth
82
87
  apitap mcp Run the full ApiTap MCP server over stdio
@@ -128,6 +133,11 @@ function printUsage(): void {
128
133
 
129
134
  Import options:
130
135
  --yes Skip confirmation prompt
136
+ --dry-run Show what would change without writing
137
+ --json Output machine-readable JSON
138
+ --from apis-guru Bulk-import from APIs.guru directory
139
+ --search <term> Filter APIs.guru entries by name/title
140
+ --limit <n> Max APIs to import (default: 100)
131
141
 
132
142
  Serve options:
133
143
  --json Output tool list as JSON on stderr
@@ -448,6 +458,20 @@ async function handleReplay(positional: string[], flags: Record<string, string |
448
458
  _skipSsrfCheck: dangerDisableSsrf,
449
459
  });
450
460
 
461
+ // Auto-upgrade imported endpoints on successful replay
462
+ if ((result as any).upgrade && endpoint) {
463
+ endpoint.confidence = 1.0;
464
+ endpoint.endpointProvenance = 'captured';
465
+ // Update example with the actual successful request
466
+ endpoint.examples.responsePreview = typeof result.data === 'object' ? result.data : null;
467
+ // Re-sign and write
468
+ const signed = signSkillFile(skill, signingKey);
469
+ await writeSkillFile(signed, SKILLS_DIR);
470
+ if (!json) {
471
+ console.error(' \u2713 Endpoint upgraded to captured (confidence 1.0)');
472
+ }
473
+ }
474
+
451
475
  if (json) {
452
476
  console.log(JSON.stringify({
453
477
  status: result.status,
@@ -455,6 +479,10 @@ async function handleReplay(positional: string[], flags: Record<string, string |
455
479
  ...(result.contractWarnings?.length ? { contractWarnings: result.contractWarnings } : {}),
456
480
  }, null, 2));
457
481
  } else {
482
+ const hint = endpoint ? getConfidenceHint(endpoint.confidence) : null;
483
+ if (hint) {
484
+ console.error(` Note: ${hint}`);
485
+ }
458
486
  console.log(`\n Status: ${result.status}\n`);
459
487
  console.log(JSON.stringify(result.data, null, 2));
460
488
  console.log();
@@ -462,35 +490,130 @@ async function handleReplay(positional: string[], flags: Record<string, string |
462
490
  }
463
491
 
464
492
  async function handleImport(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
465
- const filePath = positional[0];
466
- if (!filePath) {
467
- console.error('Error: File path required. Usage: apitap import <file>');
493
+ // --from apis-guru: bulk import mode
494
+ if (flags['from'] === 'apis-guru') {
495
+ await handleApisGuruImport(flags);
496
+ return;
497
+ }
498
+
499
+ const source = positional[0];
500
+ if (!source) {
501
+ console.error('Error: File path or URL required. Usage: apitap import <file|url>');
468
502
  process.exit(1);
469
503
  }
470
504
 
471
505
  const json = flags.json === true;
472
506
 
507
+ // Load content — from URL or file
508
+ let rawText: string;
509
+ let sourceUrl: string = source;
510
+ const isUrl = source.startsWith('http://') || source.startsWith('https://');
511
+
512
+ if (isUrl) {
513
+ try {
514
+ const ssrfCheck = await resolveAndValidateUrl(source);
515
+ if (!ssrfCheck.safe) {
516
+ throw new Error(`SSRF check failed: ${ssrfCheck.reason}`);
517
+ }
518
+ const response = await fetch(source, { signal: AbortSignal.timeout(30_000) });
519
+ if (!response.ok) {
520
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
521
+ }
522
+ rawText = await response.text();
523
+ } catch (err: any) {
524
+ const msg = `Failed to fetch ${source}: ${err.message}`;
525
+ if (json) {
526
+ console.log(JSON.stringify({ success: false, reason: msg }));
527
+ } else {
528
+ console.error(`Error: ${msg}`);
529
+ }
530
+ process.exit(1);
531
+ }
532
+ } else {
533
+ try {
534
+ const { readFile } = await import('node:fs/promises');
535
+ rawText = await readFile(source, 'utf-8');
536
+ sourceUrl = `file://${resolve(source)}`;
537
+ } catch (err: any) {
538
+ const msg = `Failed to read ${source}: ${err.message}`;
539
+ if (json) {
540
+ console.log(JSON.stringify({ success: false, reason: msg }));
541
+ } else {
542
+ console.error(`Error: ${msg}`);
543
+ }
544
+ process.exit(1);
545
+ }
546
+ }
547
+
548
+ // Parse JSON or YAML
549
+ let parsed: any;
550
+ try {
551
+ parsed = JSON.parse(rawText);
552
+ } catch {
553
+ try {
554
+ const yaml = await import('js-yaml');
555
+ parsed = yaml.load(rawText);
556
+ if (typeof parsed !== 'object' || parsed === null) {
557
+ throw new Error('YAML parsed to non-object');
558
+ }
559
+ } catch (yamlErr: any) {
560
+ if (json) {
561
+ console.log(JSON.stringify({ success: false, reason: `Invalid JSON/YAML: ${yamlErr.message}` }));
562
+ } else {
563
+ console.error(`Error: Could not parse as JSON or YAML: ${yamlErr.message}`);
564
+ }
565
+ process.exit(1);
566
+ }
567
+ }
568
+
569
+ // Route: OpenAPI spec vs SkillFile
570
+ if (isOpenAPISpec(parsed)) {
571
+ await handleOpenAPIImport(parsed, sourceUrl, flags);
572
+ return;
573
+ }
574
+
575
+ // --- Existing SkillFile import flow (unchanged) ---
576
+ if (isUrl) {
577
+ // SkillFile imports from URLs: write to temp file first
578
+ const { writeFile: writeTmp } = await import('node:fs/promises');
579
+ const { tmpdir } = await import('node:os');
580
+ const tmpPath = join(tmpdir(), `apitap-import-${Date.now()}.json`);
581
+ await writeTmp(tmpPath, rawText);
582
+ try {
583
+ await handleSkillFileImport(tmpPath, json);
584
+ } finally {
585
+ const { unlink: unlinkTmp } = await import('node:fs/promises');
586
+ await unlinkTmp(tmpPath).catch(() => {});
587
+ }
588
+ } else {
589
+ await handleSkillFileImport(source, json);
590
+ }
591
+ }
592
+
593
+ async function handleSkillFileImport(filePath: string, json: boolean): Promise<void> {
473
594
  // Get local key for signature verification
474
595
  const machineId = await getMachineId();
475
596
  const key = deriveSigningKey(machineId);
476
597
 
477
598
  // DNS-resolving SSRF check before importing (prevents DNS rebinding attacks)
599
+ let raw: any;
478
600
  try {
479
- const raw = JSON.parse(await import('node:fs/promises').then(fs => fs.readFile(filePath, 'utf-8')));
480
- if (raw.baseUrl) {
481
- const dnsCheck = await resolveAndValidateUrl(raw.baseUrl);
482
- if (!dnsCheck.safe) {
483
- const msg = `DNS rebinding risk: ${dnsCheck.reason}`;
484
- if (json) {
485
- console.log(JSON.stringify({ success: false, reason: msg }));
486
- } else {
487
- console.error(`Error: ${msg}`);
488
- }
489
- process.exit(1);
601
+ raw = JSON.parse(await import('node:fs/promises').then(fs => fs.readFile(filePath, 'utf-8')));
602
+ } catch {
603
+ // Parse errors will be caught by importSkillFile below
604
+ }
605
+
606
+ if (raw?.baseUrl) {
607
+ const dnsCheck = await resolveAndValidateUrl(raw.baseUrl);
608
+ if (!dnsCheck.safe) {
609
+ const msg = `DNS rebinding risk: ${dnsCheck.reason}`;
610
+ if (json) {
611
+ console.log(JSON.stringify({ success: false, reason: msg }));
612
+ } else {
613
+ console.error(`Error: ${msg}`);
490
614
  }
615
+ process.exit(1);
491
616
  }
492
- } catch {
493
- // Parse errors will be caught by importSkillFile
494
617
  }
495
618
 
496
619
  const result = await importSkillFile(filePath, undefined, key);
@@ -511,6 +634,306 @@ async function handleImport(positional: string[], flags: Record<string, string |
511
634
  }
512
635
  }
513
636
 
637
+ async function handleOpenAPIImport(
638
+ spec: Record<string, any>,
639
+ specUrl: string,
640
+ flags: Record<string, string | boolean>,
641
+ ): Promise<void> {
642
+ const json = flags.json === true;
643
+ const dryRun = flags['dry-run'] === true;
644
+ const update = flags.update === true;
645
+ const force = flags.force === true;
646
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
647
+
648
+ // Convert OpenAPI spec to endpoints
649
+ let importResult;
650
+ try {
651
+ importResult = convertOpenAPISpec(spec, specUrl);
652
+ } catch (err: any) {
653
+ const msg = `Failed to convert OpenAPI spec: ${err.message}`;
654
+ if (json) {
655
+ console.log(JSON.stringify({ success: false, reason: msg }));
656
+ } else {
657
+ console.error(`Error: ${msg}`);
658
+ }
659
+ process.exit(1);
660
+ }
661
+
662
+ const { domain, endpoints, meta } = importResult;
663
+ const specVersion = spec.openapi || spec.swagger || 'unknown';
664
+
665
+ if (!json) {
666
+ console.log(`\n Importing ${domain} from OpenAPI ${specVersion} spec...\n`);
667
+ }
668
+
669
+ // SSRF validate the domain
670
+ const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
671
+ if (!dnsCheck.safe) {
672
+ const msg = `DNS rebinding risk for ${domain}: ${dnsCheck.reason}`;
673
+ if (json) {
674
+ console.log(JSON.stringify({ success: false, reason: msg }));
675
+ } else {
676
+ console.error(`Error: ${msg}`);
677
+ }
678
+ process.exit(1);
679
+ }
680
+
681
+ // Read existing skill file (if any)
682
+ let existing = null;
683
+ try {
684
+ existing = await readSkillFile(domain, skillsDir, {
685
+ verifySignature: true,
686
+ trustUnsigned: true,
687
+ });
688
+ } catch (err: any) {
689
+ if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
690
+ if (!json) console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
691
+ }
692
+ }
693
+
694
+ if (!force && update && existing?.metadata.importHistory?.some(h => h.specUrl === specUrl)) {
695
+ if (json) {
696
+ console.log(JSON.stringify({ success: true, skipped: true, reason: 'Already imported from this spec URL' }));
697
+ } else {
698
+ console.log(' Already imported from this spec URL. Use --force to reimport.\n');
699
+ }
700
+ return;
701
+ }
702
+
703
+ // Merge
704
+ const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
705
+
706
+ // Ensure domain and baseUrl reflect the API, not the spec source URL
707
+ skillFile.domain = domain;
708
+ skillFile.baseUrl = `https://${domain}`;
709
+
710
+ if (dryRun) {
711
+ if (json) {
712
+ console.log(JSON.stringify({
713
+ success: true,
714
+ dryRun: true,
715
+ domain,
716
+ diff,
717
+ totalEndpoints: skillFile.endpoints.length,
718
+ }));
719
+ } else {
720
+ printOpenAPIDiff(diff, skillFile, skillsDir);
721
+ console.log(' (dry run — no changes written)\n');
722
+ }
723
+ return;
724
+ }
725
+
726
+ // Sign and write
727
+ const machineId = await getMachineId();
728
+ const key = deriveSigningKey(machineId);
729
+ // Determine provenance: 'self' if file has any captured endpoints, 'imported-signed' if all from import
730
+ const hasCaptured = skillFile.endpoints.some(
731
+ ep => !ep.endpointProvenance || ep.endpointProvenance === 'captured'
732
+ );
733
+ const signed = signSkillFileAs(skillFile, key, hasCaptured ? 'self' : 'imported-signed');
734
+ const filePath = await writeSkillFile(signed, skillsDir);
735
+
736
+ if (json) {
737
+ console.log(JSON.stringify({
738
+ success: true,
739
+ domain,
740
+ skillFile: filePath,
741
+ diff,
742
+ totalEndpoints: signed.endpoints.length,
743
+ }));
744
+ } else {
745
+ printOpenAPIDiff(diff, signed, filePath);
746
+ }
747
+ }
748
+
749
+ function printOpenAPIDiff(
750
+ diff: { preserved: number; added: number; enriched: number; skipped: number },
751
+ skillFile: { endpoints: { length: number } },
752
+ pathOrDir: string,
753
+ ): void {
754
+ console.log(` ✓ ${diff.preserved} existing captured endpoints preserved`);
755
+ console.log(` + ${diff.added} new endpoints added from OpenAPI spec`);
756
+ console.log(` ~ ${diff.enriched} endpoints enriched with spec metadata`);
757
+ console.log(` · ${diff.skipped} skipped (already imported)`);
758
+ console.log();
759
+ console.log(` Skill file: ${pathOrDir} (${skillFile.endpoints.length} endpoints)\n`);
760
+ }
761
+
762
+ async function handleApisGuruImport(flags: Record<string, string | boolean>): Promise<void> {
763
+ const json = flags.json === true;
764
+ const dryRun = flags['dry-run'] === true;
765
+ const update = flags.update === true;
766
+ const force = flags.force === true;
767
+ const limit = typeof flags.limit === 'string' ? parseInt(flags.limit, 10) : 100;
768
+ const search = typeof flags.search === 'string' ? flags.search : undefined;
769
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
770
+
771
+ if (!json) {
772
+ console.log(`\n Importing from APIs.guru (limit: ${limit})...\n`);
773
+ }
774
+
775
+ // Fetch and filter
776
+ let entries;
777
+ try {
778
+ const allEntries = await fetchApisGuruList();
779
+ entries = filterEntries(allEntries, { search, limit, preferOpenapi3: true });
780
+ } catch (err: any) {
781
+ const msg = `Failed to fetch APIs.guru list: ${err.message}`;
782
+ if (json) {
783
+ console.log(JSON.stringify({ success: false, reason: msg }));
784
+ } else {
785
+ console.error(`Error: ${msg}`);
786
+ }
787
+ process.exit(1);
788
+ }
789
+
790
+ if (entries.length === 0) {
791
+ if (json) {
792
+ console.log(JSON.stringify({ success: true, imported: 0, failed: 0, skipped: 0, totalEndpoints: 0 }));
793
+ } else {
794
+ console.log(' No matching APIs found.\n');
795
+ }
796
+ return;
797
+ }
798
+
799
+ const total = entries.length;
800
+ let imported = 0;
801
+ let failed = 0;
802
+ let skippedApis = 0;
803
+ let totalEndpointsAdded = 0;
804
+
805
+ const machineId = await getMachineId();
806
+ const key = deriveSigningKey(machineId);
807
+
808
+ const results: Array<{
809
+ index: number;
810
+ status: 'ok' | 'fail' | 'skip';
811
+ domain: string;
812
+ title: string;
813
+ endpointsAdded: number;
814
+ error?: string;
815
+ }> = [];
816
+
817
+ for (let i = 0; i < entries.length; i++) {
818
+ const entry = entries[i];
819
+ const idx = String(i + 1).padStart(String(total).length, ' ');
820
+
821
+ try {
822
+ // Fetch spec
823
+ const spec = await fetchSpec(entry.specUrl);
824
+
825
+ // Convert
826
+ const importResult = convertOpenAPISpec(spec, entry.specUrl);
827
+ const { domain, endpoints, meta } = importResult;
828
+
829
+ if (endpoints.length === 0) {
830
+ if (!json) {
831
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} 0 endpoints (${entry.title})`);
832
+ }
833
+ results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
834
+ skippedApis++;
835
+ continue;
836
+ }
837
+
838
+ // SSRF validate
839
+ const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
840
+ if (!dnsCheck.safe) {
841
+ if (!json) {
842
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} SSRF risk (${entry.title})`);
843
+ }
844
+ results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
845
+ skippedApis++;
846
+ continue;
847
+ }
848
+
849
+ // Read existing
850
+ let existing = null;
851
+ try {
852
+ existing = await readSkillFile(domain, skillsDir, {
853
+ verifySignature: true,
854
+ trustUnsigned: true,
855
+ });
856
+ } catch (err: any) {
857
+ if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') {
858
+ if (!json) console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
859
+ }
860
+ }
861
+
862
+ if (!force && update && existing?.metadata.importHistory?.length) {
863
+ const lastImport = existing.metadata.importHistory[existing.metadata.importHistory.length - 1];
864
+ if (lastImport.importedAt >= entry.updated) {
865
+ if (!json) console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} up to date`);
866
+ results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
867
+ skippedApis++;
868
+ continue;
869
+ }
870
+ }
871
+
872
+ // Merge
873
+ const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
874
+
875
+ // Ensure domain and baseUrl reflect the API, not the spec source URL
876
+ skillFile.domain = domain;
877
+ skillFile.baseUrl = `https://${domain}`;
878
+
879
+ if (!dryRun) {
880
+ // Sign and write
881
+ // Determine provenance: 'self' if file has any captured endpoints, 'imported-signed' if all from import
882
+ const hasCaptured = skillFile.endpoints.some(
883
+ ep => !ep.endpointProvenance || ep.endpointProvenance === 'captured'
884
+ );
885
+ const signed = signSkillFileAs(skillFile, key, hasCaptured ? 'self' : 'imported-signed');
886
+ await writeSkillFile(signed, skillsDir);
887
+ }
888
+
889
+ if (!json) {
890
+ console.log(` [${idx}/${total}] OK ${domain.padEnd(24)} +${diff.added} endpoints (${entry.title})`);
891
+ }
892
+ results.push({ index: i + 1, status: 'ok', domain, title: entry.title, endpointsAdded: diff.added });
893
+ imported++;
894
+ totalEndpointsAdded += diff.added;
895
+ } catch (err: any) {
896
+ if (!json) {
897
+ console.log(` [${idx}/${total}] FAIL ${entry.providerName.padEnd(24)} ${err.message}`);
898
+ }
899
+ results.push({
900
+ index: i + 1,
901
+ status: 'fail',
902
+ domain: entry.providerName,
903
+ title: entry.title,
904
+ endpointsAdded: 0,
905
+ error: err.message,
906
+ });
907
+ failed++;
908
+ }
909
+
910
+ // Small delay between requests to be polite to APIs.guru
911
+ if (i < entries.length - 1) {
912
+ await new Promise(r => setTimeout(r, 100));
913
+ }
914
+ }
915
+
916
+ if (json) {
917
+ console.log(JSON.stringify({
918
+ success: true,
919
+ dryRun,
920
+ imported,
921
+ failed,
922
+ skipped: skippedApis,
923
+ totalEndpoints: totalEndpointsAdded,
924
+ results,
925
+ }));
926
+ } else {
927
+ console.log();
928
+ console.log(` Done: ${imported} imported, ${failed} failed, ${skippedApis} skipped`);
929
+ console.log(` ${totalEndpointsAdded.toLocaleString()} endpoints added across ${imported} APIs`);
930
+ if (dryRun) {
931
+ console.log(' (dry run — no changes written)');
932
+ }
933
+ console.log();
934
+ }
935
+ }
936
+
514
937
  async function handleRefresh(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
515
938
  const domain = positional[0];
516
939
  if (!domain) {
@@ -1,6 +1,7 @@
1
1
  // src/discovery/openapi.ts
2
2
  import type { SkillEndpoint, SkillFile, DiscoveredSpec } from '../types.js';
3
3
  import { safeFetch } from './fetch.js';
4
+ import { convertOpenAPISpec } from '../skill/openapi-converter.js';
4
5
 
5
6
  /** Paths to check for API specs, in priority order */
6
7
  const SPEC_PATHS = [
@@ -137,7 +138,7 @@ export async function parseSpecToSkillFile(
137
138
 
138
139
  if (!spec.paths) return null;
139
140
 
140
- // Determine API base URL
141
+ // Determine API base URL (discovery uses the passed-in baseUrl as starting point)
141
142
  let apiBase = baseUrl;
142
143
  if (spec.servers?.[0]?.url) {
143
144
  const serverUrl = spec.servers[0].url;
@@ -147,55 +148,29 @@ export async function parseSpecToSkillFile(
147
148
  apiBase = `${scheme}://${spec.host}${spec.basePath || ''}`;
148
149
  }
149
150
 
150
- const endpoints: SkillEndpoint[] = [];
151
-
152
- for (const [path, methods] of Object.entries(spec.paths)) {
153
- for (const [method, operation] of Object.entries(methods)) {
154
- if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue;
155
- const op = operation as OpenApiOperation;
156
-
157
- // Parameterize path: {id} :id
158
- const paramPath = path.replace(/\{([^}]+)\}/g, ':$1');
159
-
160
- // Extract query params
161
- const queryParams: Record<string, { type: string; example: string }> = {};
162
- if (op.parameters) {
163
- for (const param of op.parameters) {
164
- if (param.in === 'query') {
165
- queryParams[param.name] = {
166
- type: param.schema?.type || 'string',
167
- example: '',
168
- };
169
- }
170
- }
171
- }
172
-
173
- // Generate endpoint ID
174
- const id = op.operationId
175
- ? method.toLowerCase() + '-' + op.operationId.replace(/[^a-z0-9]/gi, '-').toLowerCase()
176
- : generateId(method, paramPath);
177
-
178
- endpoints.push({
179
- id,
180
- method: method.toUpperCase(),
181
- path: paramPath,
182
- queryParams,
183
- headers: {},
184
- responseShape: { type: 'unknown' },
185
- examples: {
186
- request: { url: `${apiBase}${path}`, headers: {} },
187
- responsePreview: null,
188
- },
189
- replayability: {
190
- tier: 'unknown',
191
- verified: false,
192
- signals: ['discovered-from-spec'],
193
- },
194
- });
195
- }
196
- }
197
-
198
- if (endpoints.length === 0) return null;
151
+ // Delegate endpoint extraction to the shared converter
152
+ const { endpoints: convertedEndpoints } = convertOpenAPISpec(spec, specUrl);
153
+
154
+ if (convertedEndpoints.length === 0) return null;
155
+
156
+ // Post-process: add replayability signal and fix example URLs to use apiBase
157
+ const endpoints: SkillEndpoint[] = convertedEndpoints.map(ep => {
158
+ // Rebuild example URL using apiBase (converter uses https://<domain> but discovery
159
+ // should use the baseUrl passed in, which may be http:// or a different host)
160
+ const exampleUrl = ep.examples.request.url.replace(/^https?:\/\/[^/]+/, apiBase.replace(/\/$/, ''));
161
+ return {
162
+ ...ep,
163
+ examples: {
164
+ ...ep.examples,
165
+ request: { ...ep.examples.request, url: exampleUrl },
166
+ },
167
+ replayability: {
168
+ tier: 'unknown',
169
+ verified: false,
170
+ signals: ['discovered-from-spec'],
171
+ },
172
+ };
173
+ });
199
174
 
200
175
  return {
201
176
  version: '1.2',
@@ -212,12 +187,6 @@ export async function parseSpecToSkillFile(
212
187
  };
213
188
  }
214
189
 
215
- function generateId(method: string, path: string): string {
216
- const segments = path.split('/').filter(s => s !== '' && !s.startsWith(':'));
217
- const slug = segments.join('-').replace(/[^a-z0-9-]/gi, '').toLowerCase() || 'root';
218
- return `${method.toLowerCase()}-${slug}`;
219
- }
220
-
221
190
  function parseLinkHeader(header: string, rel: string): string | null {
222
191
  const parts = header.split(',');
223
192
  for (const part of parts) {