@apitap/core 1.5.4 → 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.
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
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
8
  import { signSkillFile } from './skill/signing.js';
@@ -26,6 +26,9 @@ import { stat, unlink } from 'node:fs/promises';
26
26
  import { fileURLToPath } from 'node:url';
27
27
  import { createMcpServer } from './mcp.js';
28
28
  import { attach, parseDomainPatterns } from './capture/cdp-attach.js';
29
+ import { isOpenAPISpec, convertOpenAPISpec } from './skill/openapi-converter.js';
30
+ import { mergeSkillFile } from './skill/merge.js';
31
+ import { fetchApisGuruList, filterEntries, fetchSpec } from './skill/apis-guru.js';
29
32
  const __dirname = fileURLToPath(new URL('.', import.meta.url));
30
33
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
31
34
  const VERSION = pkg.version;
@@ -66,7 +69,9 @@ function printUsage() {
66
69
  apitap show <domain> Show endpoints for a domain
67
70
  apitap replay <domain> <endpoint-id> [key=value...]
68
71
  Replay an API endpoint
69
- apitap import <file> Import a skill file with safety validation
72
+ apitap import <file|url> Import a skill file or OpenAPI spec
73
+ apitap import --from apis-guru
74
+ Bulk-import from APIs.guru directory
70
75
  apitap refresh <domain> Refresh auth tokens via browser
71
76
  apitap auth [domain] View or manage stored auth
72
77
  apitap mcp Run the full ApiTap MCP server over stdio
@@ -118,6 +123,11 @@ function printUsage() {
118
123
 
119
124
  Import options:
120
125
  --yes Skip confirmation prompt
126
+ --dry-run Show what would change without writing
127
+ --json Output machine-readable JSON
128
+ --from apis-guru Bulk-import from APIs.guru directory
129
+ --search <term> Filter APIs.guru entries by name/title
130
+ --limit <n> Max APIs to import (default: 100)
121
131
 
122
132
  Serve options:
123
133
  --json Output tool list as JSON on stderr
@@ -408,40 +418,145 @@ async function handleReplay(positional, flags) {
408
418
  }, null, 2));
409
419
  }
410
420
  else {
421
+ const hint = endpoint ? getConfidenceHint(endpoint.confidence) : null;
422
+ if (hint) {
423
+ console.error(` Note: ${hint}`);
424
+ }
411
425
  console.log(`\n Status: ${result.status}\n`);
412
426
  console.log(JSON.stringify(result.data, null, 2));
413
427
  console.log();
414
428
  }
415
429
  }
416
430
  async function handleImport(positional, flags) {
417
- const filePath = positional[0];
418
- if (!filePath) {
419
- console.error('Error: File path required. Usage: apitap import <file>');
431
+ // --from apis-guru: bulk import mode
432
+ if (flags['from'] === 'apis-guru') {
433
+ await handleApisGuruImport(flags);
434
+ return;
435
+ }
436
+ const source = positional[0];
437
+ if (!source) {
438
+ console.error('Error: File path or URL required. Usage: apitap import <file|url>');
420
439
  process.exit(1);
421
440
  }
422
441
  const json = flags.json === true;
442
+ // Reject YAML files with helpful message
443
+ if (/\.ya?ml$/i.test(source)) {
444
+ const msg = 'YAML specs not yet supported. Convert to JSON first: npx swagger-cli bundle spec.yaml -o spec.json';
445
+ if (json) {
446
+ console.log(JSON.stringify({ success: false, reason: msg }));
447
+ }
448
+ else {
449
+ console.error(`Error: ${msg}`);
450
+ }
451
+ process.exit(1);
452
+ }
453
+ // Load content — from URL or file
454
+ let rawText;
455
+ let sourceUrl = source;
456
+ const isUrl = source.startsWith('http://') || source.startsWith('https://');
457
+ if (isUrl) {
458
+ try {
459
+ const ssrfCheck = await resolveAndValidateUrl(source);
460
+ if (!ssrfCheck.safe) {
461
+ throw new Error(`SSRF check failed: ${ssrfCheck.reason}`);
462
+ }
463
+ const response = await fetch(source, { signal: AbortSignal.timeout(30_000) });
464
+ if (!response.ok) {
465
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
466
+ }
467
+ rawText = await response.text();
468
+ }
469
+ catch (err) {
470
+ const msg = `Failed to fetch ${source}: ${err.message}`;
471
+ if (json) {
472
+ console.log(JSON.stringify({ success: false, reason: msg }));
473
+ }
474
+ else {
475
+ console.error(`Error: ${msg}`);
476
+ }
477
+ process.exit(1);
478
+ }
479
+ }
480
+ else {
481
+ try {
482
+ const { readFile } = await import('node:fs/promises');
483
+ rawText = await readFile(source, 'utf-8');
484
+ sourceUrl = `file://${resolve(source)}`;
485
+ }
486
+ catch (err) {
487
+ const msg = `Failed to read ${source}: ${err.message}`;
488
+ if (json) {
489
+ console.log(JSON.stringify({ success: false, reason: msg }));
490
+ }
491
+ else {
492
+ console.error(`Error: ${msg}`);
493
+ }
494
+ process.exit(1);
495
+ }
496
+ }
497
+ // Parse JSON
498
+ let parsed;
499
+ try {
500
+ parsed = JSON.parse(rawText);
501
+ }
502
+ catch {
503
+ const msg = `Invalid JSON in ${source}`;
504
+ if (json) {
505
+ console.log(JSON.stringify({ success: false, reason: msg }));
506
+ }
507
+ else {
508
+ console.error(`Error: ${msg}`);
509
+ }
510
+ process.exit(1);
511
+ }
512
+ // Route: OpenAPI spec vs SkillFile
513
+ if (isOpenAPISpec(parsed)) {
514
+ await handleOpenAPIImport(parsed, sourceUrl, flags);
515
+ return;
516
+ }
517
+ // --- Existing SkillFile import flow (unchanged) ---
518
+ if (isUrl) {
519
+ // SkillFile imports from URLs: write to temp file first
520
+ const { writeFile: writeTmp } = await import('node:fs/promises');
521
+ const { tmpdir } = await import('node:os');
522
+ const tmpPath = join(tmpdir(), `apitap-import-${Date.now()}.json`);
523
+ await writeTmp(tmpPath, rawText);
524
+ try {
525
+ await handleSkillFileImport(tmpPath, json);
526
+ }
527
+ finally {
528
+ const { unlink: unlinkTmp } = await import('node:fs/promises');
529
+ await unlinkTmp(tmpPath).catch(() => { });
530
+ }
531
+ }
532
+ else {
533
+ await handleSkillFileImport(source, json);
534
+ }
535
+ }
536
+ async function handleSkillFileImport(filePath, json) {
423
537
  // Get local key for signature verification
424
538
  const machineId = await getMachineId();
425
539
  const key = deriveSigningKey(machineId);
426
540
  // DNS-resolving SSRF check before importing (prevents DNS rebinding attacks)
541
+ let raw;
427
542
  try {
428
- const raw = JSON.parse(await import('node:fs/promises').then(fs => fs.readFile(filePath, 'utf-8')));
429
- if (raw.baseUrl) {
430
- const dnsCheck = await resolveAndValidateUrl(raw.baseUrl);
431
- if (!dnsCheck.safe) {
432
- const msg = `DNS rebinding risk: ${dnsCheck.reason}`;
433
- if (json) {
434
- console.log(JSON.stringify({ success: false, reason: msg }));
435
- }
436
- else {
437
- console.error(`Error: ${msg}`);
438
- }
439
- process.exit(1);
440
- }
441
- }
543
+ raw = JSON.parse(await import('node:fs/promises').then(fs => fs.readFile(filePath, 'utf-8')));
442
544
  }
443
545
  catch {
444
- // Parse errors will be caught by importSkillFile
546
+ // Parse errors will be caught by importSkillFile below
547
+ }
548
+ if (raw?.baseUrl) {
549
+ const dnsCheck = await resolveAndValidateUrl(raw.baseUrl);
550
+ if (!dnsCheck.safe) {
551
+ const msg = `DNS rebinding risk: ${dnsCheck.reason}`;
552
+ if (json) {
553
+ console.log(JSON.stringify({ success: false, reason: msg }));
554
+ }
555
+ else {
556
+ console.error(`Error: ${msg}`);
557
+ }
558
+ process.exit(1);
559
+ }
445
560
  }
446
561
  const result = await importSkillFile(filePath, undefined, key);
447
562
  if (!result.success) {
@@ -460,6 +575,243 @@ async function handleImport(positional, flags) {
460
575
  console.log(`\n ✓ Imported skill file: ${result.skillFile}\n`);
461
576
  }
462
577
  }
578
+ async function handleOpenAPIImport(spec, specUrl, flags) {
579
+ const json = flags.json === true;
580
+ const dryRun = flags['dry-run'] === true;
581
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
582
+ // Convert OpenAPI spec to endpoints
583
+ let importResult;
584
+ try {
585
+ importResult = convertOpenAPISpec(spec, specUrl);
586
+ }
587
+ catch (err) {
588
+ const msg = `Failed to convert OpenAPI spec: ${err.message}`;
589
+ if (json) {
590
+ console.log(JSON.stringify({ success: false, reason: msg }));
591
+ }
592
+ else {
593
+ console.error(`Error: ${msg}`);
594
+ }
595
+ process.exit(1);
596
+ }
597
+ const { domain, endpoints, meta } = importResult;
598
+ const specVersion = spec.openapi || spec.swagger || 'unknown';
599
+ if (!json) {
600
+ console.log(`\n Importing ${domain} from OpenAPI ${specVersion} spec...\n`);
601
+ }
602
+ // SSRF validate the domain
603
+ const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
604
+ if (!dnsCheck.safe) {
605
+ const msg = `DNS rebinding risk for ${domain}: ${dnsCheck.reason}`;
606
+ if (json) {
607
+ console.log(JSON.stringify({ success: false, reason: msg }));
608
+ }
609
+ else {
610
+ console.error(`Error: ${msg}`);
611
+ }
612
+ process.exit(1);
613
+ }
614
+ // Read existing skill file (if any)
615
+ let existing = null;
616
+ try {
617
+ existing = await readSkillFile(domain, skillsDir, {
618
+ verifySignature: true,
619
+ trustUnsigned: true,
620
+ });
621
+ }
622
+ catch (err) {
623
+ if (err?.code !== 'ENOENT') {
624
+ if (!json)
625
+ console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
626
+ }
627
+ }
628
+ // Merge
629
+ const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
630
+ // Ensure domain and baseUrl reflect the API, not the spec source URL
631
+ skillFile.domain = domain;
632
+ skillFile.baseUrl = `https://${domain}`;
633
+ if (dryRun) {
634
+ if (json) {
635
+ console.log(JSON.stringify({
636
+ success: true,
637
+ dryRun: true,
638
+ domain,
639
+ diff,
640
+ totalEndpoints: skillFile.endpoints.length,
641
+ }));
642
+ }
643
+ else {
644
+ printOpenAPIDiff(diff, skillFile, skillsDir);
645
+ console.log(' (dry run — no changes written)\n');
646
+ }
647
+ return;
648
+ }
649
+ // Sign and write
650
+ const machineId = await getMachineId();
651
+ const key = deriveSigningKey(machineId);
652
+ const signed = signSkillFile(skillFile, key);
653
+ const filePath = await writeSkillFile(signed, skillsDir);
654
+ if (json) {
655
+ console.log(JSON.stringify({
656
+ success: true,
657
+ domain,
658
+ skillFile: filePath,
659
+ diff,
660
+ totalEndpoints: signed.endpoints.length,
661
+ }));
662
+ }
663
+ else {
664
+ printOpenAPIDiff(diff, signed, filePath);
665
+ }
666
+ }
667
+ function printOpenAPIDiff(diff, skillFile, pathOrDir) {
668
+ console.log(` ✓ ${diff.preserved} existing captured endpoints preserved`);
669
+ console.log(` + ${diff.added} new endpoints added from OpenAPI spec`);
670
+ console.log(` ~ ${diff.enriched} endpoints enriched with spec metadata`);
671
+ console.log(` · ${diff.skipped} skipped (already imported)`);
672
+ console.log();
673
+ console.log(` Skill file: ${pathOrDir} (${skillFile.endpoints.length} endpoints)\n`);
674
+ }
675
+ async function handleApisGuruImport(flags) {
676
+ const json = flags.json === true;
677
+ const dryRun = flags['dry-run'] === true;
678
+ const limit = typeof flags.limit === 'string' ? parseInt(flags.limit, 10) : 100;
679
+ const search = typeof flags.search === 'string' ? flags.search : undefined;
680
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
681
+ if (!json) {
682
+ console.log(`\n Importing from APIs.guru (limit: ${limit})...\n`);
683
+ }
684
+ // Fetch and filter
685
+ let entries;
686
+ try {
687
+ const allEntries = await fetchApisGuruList();
688
+ entries = filterEntries(allEntries, { search, limit, preferOpenapi3: true });
689
+ }
690
+ catch (err) {
691
+ const msg = `Failed to fetch APIs.guru list: ${err.message}`;
692
+ if (json) {
693
+ console.log(JSON.stringify({ success: false, reason: msg }));
694
+ }
695
+ else {
696
+ console.error(`Error: ${msg}`);
697
+ }
698
+ process.exit(1);
699
+ }
700
+ if (entries.length === 0) {
701
+ if (json) {
702
+ console.log(JSON.stringify({ success: true, imported: 0, failed: 0, skipped: 0, totalEndpoints: 0 }));
703
+ }
704
+ else {
705
+ console.log(' No matching APIs found.\n');
706
+ }
707
+ return;
708
+ }
709
+ const total = entries.length;
710
+ let imported = 0;
711
+ let failed = 0;
712
+ let skippedApis = 0;
713
+ let totalEndpointsAdded = 0;
714
+ const machineId = await getMachineId();
715
+ const key = deriveSigningKey(machineId);
716
+ const results = [];
717
+ for (let i = 0; i < entries.length; i++) {
718
+ const entry = entries[i];
719
+ const idx = String(i + 1).padStart(String(total).length, ' ');
720
+ try {
721
+ // Fetch spec
722
+ const spec = await fetchSpec(entry.specUrl);
723
+ // Convert
724
+ const importResult = convertOpenAPISpec(spec, entry.specUrl);
725
+ const { domain, endpoints, meta } = importResult;
726
+ if (endpoints.length === 0) {
727
+ if (!json) {
728
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} 0 endpoints (${entry.title})`);
729
+ }
730
+ results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
731
+ skippedApis++;
732
+ continue;
733
+ }
734
+ // SSRF validate
735
+ const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
736
+ if (!dnsCheck.safe) {
737
+ if (!json) {
738
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} SSRF risk (${entry.title})`);
739
+ }
740
+ results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
741
+ skippedApis++;
742
+ continue;
743
+ }
744
+ // Read existing
745
+ let existing = null;
746
+ try {
747
+ existing = await readSkillFile(domain, skillsDir, {
748
+ verifySignature: true,
749
+ trustUnsigned: true,
750
+ });
751
+ }
752
+ catch (err) {
753
+ if (err?.code !== 'ENOENT') {
754
+ if (!json)
755
+ console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
756
+ }
757
+ }
758
+ // Merge
759
+ const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
760
+ // Ensure domain and baseUrl reflect the API, not the spec source URL
761
+ skillFile.domain = domain;
762
+ skillFile.baseUrl = `https://${domain}`;
763
+ if (!dryRun) {
764
+ // Sign and write
765
+ const signed = signSkillFile(skillFile, key);
766
+ await writeSkillFile(signed, skillsDir);
767
+ }
768
+ if (!json) {
769
+ console.log(` [${idx}/${total}] OK ${domain.padEnd(24)} +${diff.added} endpoints (${entry.title})`);
770
+ }
771
+ results.push({ index: i + 1, status: 'ok', domain, title: entry.title, endpointsAdded: diff.added });
772
+ imported++;
773
+ totalEndpointsAdded += diff.added;
774
+ }
775
+ catch (err) {
776
+ if (!json) {
777
+ console.log(` [${idx}/${total}] FAIL ${entry.providerName.padEnd(24)} ${err.message}`);
778
+ }
779
+ results.push({
780
+ index: i + 1,
781
+ status: 'fail',
782
+ domain: entry.providerName,
783
+ title: entry.title,
784
+ endpointsAdded: 0,
785
+ error: err.message,
786
+ });
787
+ failed++;
788
+ }
789
+ // Small delay between requests to be polite to APIs.guru
790
+ if (i < entries.length - 1) {
791
+ await new Promise(r => setTimeout(r, 100));
792
+ }
793
+ }
794
+ if (json) {
795
+ console.log(JSON.stringify({
796
+ success: true,
797
+ dryRun,
798
+ imported,
799
+ failed,
800
+ skipped: skippedApis,
801
+ totalEndpoints: totalEndpointsAdded,
802
+ results,
803
+ }));
804
+ }
805
+ else {
806
+ console.log();
807
+ console.log(` Done: ${imported} imported, ${failed} failed, ${skippedApis} skipped`);
808
+ console.log(` ${totalEndpointsAdded.toLocaleString()} endpoints added across ${imported} APIs`);
809
+ if (dryRun) {
810
+ console.log(' (dry run — no changes written)');
811
+ }
812
+ console.log();
813
+ }
814
+ }
463
815
  async function handleRefresh(positional, flags) {
464
816
  const domain = positional[0];
465
817
  if (!domain) {