@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/dist/cli.js 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';
@@ -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
@@ -400,6 +410,19 @@ async function handleReplay(positional, flags) {
400
410
  maxBytes,
401
411
  _skipSsrfCheck: dangerDisableSsrf,
402
412
  });
413
+ // Auto-upgrade imported endpoints on successful replay
414
+ if (result.upgrade && endpoint) {
415
+ endpoint.confidence = 1.0;
416
+ endpoint.endpointProvenance = 'captured';
417
+ // Update example with the actual successful request
418
+ endpoint.examples.responsePreview = typeof result.data === 'object' ? result.data : null;
419
+ // Re-sign and write
420
+ const signed = signSkillFile(skill, signingKey);
421
+ await writeSkillFile(signed, SKILLS_DIR);
422
+ if (!json) {
423
+ console.error(' \u2713 Endpoint upgraded to captured (confidence 1.0)');
424
+ }
425
+ }
403
426
  if (json) {
404
427
  console.log(JSON.stringify({
405
428
  status: result.status,
@@ -408,40 +431,142 @@ async function handleReplay(positional, flags) {
408
431
  }, null, 2));
409
432
  }
410
433
  else {
434
+ const hint = endpoint ? getConfidenceHint(endpoint.confidence) : null;
435
+ if (hint) {
436
+ console.error(` Note: ${hint}`);
437
+ }
411
438
  console.log(`\n Status: ${result.status}\n`);
412
439
  console.log(JSON.stringify(result.data, null, 2));
413
440
  console.log();
414
441
  }
415
442
  }
416
443
  async function handleImport(positional, flags) {
417
- const filePath = positional[0];
418
- if (!filePath) {
419
- console.error('Error: File path required. Usage: apitap import <file>');
444
+ // --from apis-guru: bulk import mode
445
+ if (flags['from'] === 'apis-guru') {
446
+ await handleApisGuruImport(flags);
447
+ return;
448
+ }
449
+ const source = positional[0];
450
+ if (!source) {
451
+ console.error('Error: File path or URL required. Usage: apitap import <file|url>');
420
452
  process.exit(1);
421
453
  }
422
454
  const json = flags.json === true;
455
+ // Load content — from URL or file
456
+ let rawText;
457
+ let sourceUrl = source;
458
+ const isUrl = source.startsWith('http://') || source.startsWith('https://');
459
+ if (isUrl) {
460
+ try {
461
+ const ssrfCheck = await resolveAndValidateUrl(source);
462
+ if (!ssrfCheck.safe) {
463
+ throw new Error(`SSRF check failed: ${ssrfCheck.reason}`);
464
+ }
465
+ const response = await fetch(source, { signal: AbortSignal.timeout(30_000) });
466
+ if (!response.ok) {
467
+ throw new Error(`HTTP ${response.status} ${response.statusText}`);
468
+ }
469
+ rawText = await response.text();
470
+ }
471
+ catch (err) {
472
+ const msg = `Failed to fetch ${source}: ${err.message}`;
473
+ if (json) {
474
+ console.log(JSON.stringify({ success: false, reason: msg }));
475
+ }
476
+ else {
477
+ console.error(`Error: ${msg}`);
478
+ }
479
+ process.exit(1);
480
+ }
481
+ }
482
+ else {
483
+ try {
484
+ const { readFile } = await import('node:fs/promises');
485
+ rawText = await readFile(source, 'utf-8');
486
+ sourceUrl = `file://${resolve(source)}`;
487
+ }
488
+ catch (err) {
489
+ const msg = `Failed to read ${source}: ${err.message}`;
490
+ if (json) {
491
+ console.log(JSON.stringify({ success: false, reason: msg }));
492
+ }
493
+ else {
494
+ console.error(`Error: ${msg}`);
495
+ }
496
+ process.exit(1);
497
+ }
498
+ }
499
+ // Parse JSON or YAML
500
+ let parsed;
501
+ try {
502
+ parsed = JSON.parse(rawText);
503
+ }
504
+ catch {
505
+ try {
506
+ const yaml = await import('js-yaml');
507
+ parsed = yaml.load(rawText);
508
+ if (typeof parsed !== 'object' || parsed === null) {
509
+ throw new Error('YAML parsed to non-object');
510
+ }
511
+ }
512
+ catch (yamlErr) {
513
+ if (json) {
514
+ console.log(JSON.stringify({ success: false, reason: `Invalid JSON/YAML: ${yamlErr.message}` }));
515
+ }
516
+ else {
517
+ console.error(`Error: Could not parse as JSON or YAML: ${yamlErr.message}`);
518
+ }
519
+ process.exit(1);
520
+ }
521
+ }
522
+ // Route: OpenAPI spec vs SkillFile
523
+ if (isOpenAPISpec(parsed)) {
524
+ await handleOpenAPIImport(parsed, sourceUrl, flags);
525
+ return;
526
+ }
527
+ // --- Existing SkillFile import flow (unchanged) ---
528
+ if (isUrl) {
529
+ // SkillFile imports from URLs: write to temp file first
530
+ const { writeFile: writeTmp } = await import('node:fs/promises');
531
+ const { tmpdir } = await import('node:os');
532
+ const tmpPath = join(tmpdir(), `apitap-import-${Date.now()}.json`);
533
+ await writeTmp(tmpPath, rawText);
534
+ try {
535
+ await handleSkillFileImport(tmpPath, json);
536
+ }
537
+ finally {
538
+ const { unlink: unlinkTmp } = await import('node:fs/promises');
539
+ await unlinkTmp(tmpPath).catch(() => { });
540
+ }
541
+ }
542
+ else {
543
+ await handleSkillFileImport(source, json);
544
+ }
545
+ }
546
+ async function handleSkillFileImport(filePath, json) {
423
547
  // Get local key for signature verification
424
548
  const machineId = await getMachineId();
425
549
  const key = deriveSigningKey(machineId);
426
550
  // DNS-resolving SSRF check before importing (prevents DNS rebinding attacks)
551
+ let raw;
427
552
  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
- }
553
+ raw = JSON.parse(await import('node:fs/promises').then(fs => fs.readFile(filePath, 'utf-8')));
442
554
  }
443
555
  catch {
444
- // Parse errors will be caught by importSkillFile
556
+ // Parse errors will be caught by importSkillFile below
557
+ }
558
+ if (raw?.baseUrl) {
559
+ const dnsCheck = await resolveAndValidateUrl(raw.baseUrl);
560
+ if (!dnsCheck.safe) {
561
+ const msg = `DNS rebinding risk: ${dnsCheck.reason}`;
562
+ if (json) {
563
+ console.log(JSON.stringify({ success: false, reason: msg }));
564
+ }
565
+ else {
566
+ console.error(`Error: ${msg}`);
567
+ }
568
+ process.exit(1);
569
+ }
445
570
  }
446
571
  const result = await importSkillFile(filePath, undefined, key);
447
572
  if (!result.success) {
@@ -460,6 +585,270 @@ async function handleImport(positional, flags) {
460
585
  console.log(`\n ✓ Imported skill file: ${result.skillFile}\n`);
461
586
  }
462
587
  }
588
+ async function handleOpenAPIImport(spec, specUrl, flags) {
589
+ const json = flags.json === true;
590
+ const dryRun = flags['dry-run'] === true;
591
+ const update = flags.update === true;
592
+ const force = flags.force === true;
593
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
594
+ // Convert OpenAPI spec to endpoints
595
+ let importResult;
596
+ try {
597
+ importResult = convertOpenAPISpec(spec, specUrl);
598
+ }
599
+ catch (err) {
600
+ const msg = `Failed to convert OpenAPI spec: ${err.message}`;
601
+ if (json) {
602
+ console.log(JSON.stringify({ success: false, reason: msg }));
603
+ }
604
+ else {
605
+ console.error(`Error: ${msg}`);
606
+ }
607
+ process.exit(1);
608
+ }
609
+ const { domain, endpoints, meta } = importResult;
610
+ const specVersion = spec.openapi || spec.swagger || 'unknown';
611
+ if (!json) {
612
+ console.log(`\n Importing ${domain} from OpenAPI ${specVersion} spec...\n`);
613
+ }
614
+ // SSRF validate the domain
615
+ const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
616
+ if (!dnsCheck.safe) {
617
+ const msg = `DNS rebinding risk for ${domain}: ${dnsCheck.reason}`;
618
+ if (json) {
619
+ console.log(JSON.stringify({ success: false, reason: msg }));
620
+ }
621
+ else {
622
+ console.error(`Error: ${msg}`);
623
+ }
624
+ process.exit(1);
625
+ }
626
+ // Read existing skill file (if any)
627
+ let existing = null;
628
+ try {
629
+ existing = await readSkillFile(domain, skillsDir, {
630
+ verifySignature: true,
631
+ trustUnsigned: true,
632
+ });
633
+ }
634
+ catch (err) {
635
+ if (err?.code !== 'ENOENT') {
636
+ if (!json)
637
+ console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
638
+ }
639
+ }
640
+ if (!force && update && existing?.metadata.importHistory?.some(h => h.specUrl === specUrl)) {
641
+ if (json) {
642
+ console.log(JSON.stringify({ success: true, skipped: true, reason: 'Already imported from this spec URL' }));
643
+ }
644
+ else {
645
+ console.log(' Already imported from this spec URL. Use --force to reimport.\n');
646
+ }
647
+ return;
648
+ }
649
+ // Merge
650
+ const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
651
+ // Ensure domain and baseUrl reflect the API, not the spec source URL
652
+ skillFile.domain = domain;
653
+ skillFile.baseUrl = `https://${domain}`;
654
+ if (dryRun) {
655
+ if (json) {
656
+ console.log(JSON.stringify({
657
+ success: true,
658
+ dryRun: true,
659
+ domain,
660
+ diff,
661
+ totalEndpoints: skillFile.endpoints.length,
662
+ }));
663
+ }
664
+ else {
665
+ printOpenAPIDiff(diff, skillFile, skillsDir);
666
+ console.log(' (dry run — no changes written)\n');
667
+ }
668
+ return;
669
+ }
670
+ // Sign and write
671
+ const machineId = await getMachineId();
672
+ const key = deriveSigningKey(machineId);
673
+ // Determine provenance: 'self' if file has any captured endpoints, 'imported-signed' if all from import
674
+ const hasCaptured = skillFile.endpoints.some(ep => !ep.endpointProvenance || ep.endpointProvenance === 'captured');
675
+ const signed = signSkillFileAs(skillFile, key, hasCaptured ? 'self' : 'imported-signed');
676
+ const filePath = await writeSkillFile(signed, skillsDir);
677
+ if (json) {
678
+ console.log(JSON.stringify({
679
+ success: true,
680
+ domain,
681
+ skillFile: filePath,
682
+ diff,
683
+ totalEndpoints: signed.endpoints.length,
684
+ }));
685
+ }
686
+ else {
687
+ printOpenAPIDiff(diff, signed, filePath);
688
+ }
689
+ }
690
+ function printOpenAPIDiff(diff, skillFile, pathOrDir) {
691
+ console.log(` ✓ ${diff.preserved} existing captured endpoints preserved`);
692
+ console.log(` + ${diff.added} new endpoints added from OpenAPI spec`);
693
+ console.log(` ~ ${diff.enriched} endpoints enriched with spec metadata`);
694
+ console.log(` · ${diff.skipped} skipped (already imported)`);
695
+ console.log();
696
+ console.log(` Skill file: ${pathOrDir} (${skillFile.endpoints.length} endpoints)\n`);
697
+ }
698
+ async function handleApisGuruImport(flags) {
699
+ const json = flags.json === true;
700
+ const dryRun = flags['dry-run'] === true;
701
+ const update = flags.update === true;
702
+ const force = flags.force === true;
703
+ const limit = typeof flags.limit === 'string' ? parseInt(flags.limit, 10) : 100;
704
+ const search = typeof flags.search === 'string' ? flags.search : undefined;
705
+ const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
706
+ if (!json) {
707
+ console.log(`\n Importing from APIs.guru (limit: ${limit})...\n`);
708
+ }
709
+ // Fetch and filter
710
+ let entries;
711
+ try {
712
+ const allEntries = await fetchApisGuruList();
713
+ entries = filterEntries(allEntries, { search, limit, preferOpenapi3: true });
714
+ }
715
+ catch (err) {
716
+ const msg = `Failed to fetch APIs.guru list: ${err.message}`;
717
+ if (json) {
718
+ console.log(JSON.stringify({ success: false, reason: msg }));
719
+ }
720
+ else {
721
+ console.error(`Error: ${msg}`);
722
+ }
723
+ process.exit(1);
724
+ }
725
+ if (entries.length === 0) {
726
+ if (json) {
727
+ console.log(JSON.stringify({ success: true, imported: 0, failed: 0, skipped: 0, totalEndpoints: 0 }));
728
+ }
729
+ else {
730
+ console.log(' No matching APIs found.\n');
731
+ }
732
+ return;
733
+ }
734
+ const total = entries.length;
735
+ let imported = 0;
736
+ let failed = 0;
737
+ let skippedApis = 0;
738
+ let totalEndpointsAdded = 0;
739
+ const machineId = await getMachineId();
740
+ const key = deriveSigningKey(machineId);
741
+ const results = [];
742
+ for (let i = 0; i < entries.length; i++) {
743
+ const entry = entries[i];
744
+ const idx = String(i + 1).padStart(String(total).length, ' ');
745
+ try {
746
+ // Fetch spec
747
+ const spec = await fetchSpec(entry.specUrl);
748
+ // Convert
749
+ const importResult = convertOpenAPISpec(spec, entry.specUrl);
750
+ const { domain, endpoints, meta } = importResult;
751
+ if (endpoints.length === 0) {
752
+ if (!json) {
753
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} 0 endpoints (${entry.title})`);
754
+ }
755
+ results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
756
+ skippedApis++;
757
+ continue;
758
+ }
759
+ // SSRF validate
760
+ const dnsCheck = await resolveAndValidateUrl(`https://${domain}`);
761
+ if (!dnsCheck.safe) {
762
+ if (!json) {
763
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} SSRF risk (${entry.title})`);
764
+ }
765
+ results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
766
+ skippedApis++;
767
+ continue;
768
+ }
769
+ // Read existing
770
+ let existing = null;
771
+ try {
772
+ existing = await readSkillFile(domain, skillsDir, {
773
+ verifySignature: true,
774
+ trustUnsigned: true,
775
+ });
776
+ }
777
+ catch (err) {
778
+ if (err?.code !== 'ENOENT') {
779
+ if (!json)
780
+ console.error(` Warning: could not read existing skill file for ${domain}: ${err.message}`);
781
+ }
782
+ }
783
+ if (!force && update && existing?.metadata.importHistory?.length) {
784
+ const lastImport = existing.metadata.importHistory[existing.metadata.importHistory.length - 1];
785
+ if (lastImport.importedAt >= entry.updated) {
786
+ if (!json)
787
+ console.log(` [${idx}/${total}] SKIP ${domain.padEnd(24)} up to date`);
788
+ results.push({ index: i + 1, status: 'skip', domain, title: entry.title, endpointsAdded: 0 });
789
+ skippedApis++;
790
+ continue;
791
+ }
792
+ }
793
+ // Merge
794
+ const { skillFile, diff } = mergeSkillFile(existing, endpoints, meta);
795
+ // Ensure domain and baseUrl reflect the API, not the spec source URL
796
+ skillFile.domain = domain;
797
+ skillFile.baseUrl = `https://${domain}`;
798
+ if (!dryRun) {
799
+ // Sign and write
800
+ // Determine provenance: 'self' if file has any captured endpoints, 'imported-signed' if all from import
801
+ const hasCaptured = skillFile.endpoints.some(ep => !ep.endpointProvenance || ep.endpointProvenance === 'captured');
802
+ const signed = signSkillFileAs(skillFile, key, hasCaptured ? 'self' : 'imported-signed');
803
+ await writeSkillFile(signed, skillsDir);
804
+ }
805
+ if (!json) {
806
+ console.log(` [${idx}/${total}] OK ${domain.padEnd(24)} +${diff.added} endpoints (${entry.title})`);
807
+ }
808
+ results.push({ index: i + 1, status: 'ok', domain, title: entry.title, endpointsAdded: diff.added });
809
+ imported++;
810
+ totalEndpointsAdded += diff.added;
811
+ }
812
+ catch (err) {
813
+ if (!json) {
814
+ console.log(` [${idx}/${total}] FAIL ${entry.providerName.padEnd(24)} ${err.message}`);
815
+ }
816
+ results.push({
817
+ index: i + 1,
818
+ status: 'fail',
819
+ domain: entry.providerName,
820
+ title: entry.title,
821
+ endpointsAdded: 0,
822
+ error: err.message,
823
+ });
824
+ failed++;
825
+ }
826
+ // Small delay between requests to be polite to APIs.guru
827
+ if (i < entries.length - 1) {
828
+ await new Promise(r => setTimeout(r, 100));
829
+ }
830
+ }
831
+ if (json) {
832
+ console.log(JSON.stringify({
833
+ success: true,
834
+ dryRun,
835
+ imported,
836
+ failed,
837
+ skipped: skippedApis,
838
+ totalEndpoints: totalEndpointsAdded,
839
+ results,
840
+ }));
841
+ }
842
+ else {
843
+ console.log();
844
+ console.log(` Done: ${imported} imported, ${failed} failed, ${skippedApis} skipped`);
845
+ console.log(` ${totalEndpointsAdded.toLocaleString()} endpoints added across ${imported} APIs`);
846
+ if (dryRun) {
847
+ console.log(' (dry run — no changes written)');
848
+ }
849
+ console.log();
850
+ }
851
+ }
463
852
  async function handleRefresh(positional, flags) {
464
853
  const domain = positional[0];
465
854
  if (!domain) {