@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 +410 -21
- package/dist/cli.js.map +1 -1
- package/dist/discovery/openapi.js +23 -50
- package/dist/discovery/openapi.js.map +1 -1
- package/dist/replay/engine.d.ts +19 -0
- package/dist/replay/engine.js +46 -5
- package/dist/replay/engine.js.map +1 -1
- package/dist/skill/apis-guru.d.ts +35 -0
- package/dist/skill/apis-guru.js +136 -0
- package/dist/skill/apis-guru.js.map +1 -0
- package/dist/skill/merge.d.ts +29 -0
- package/dist/skill/merge.js +252 -0
- package/dist/skill/merge.js.map +1 -0
- package/dist/skill/openapi-converter.d.ts +31 -0
- package/dist/skill/openapi-converter.js +390 -0
- package/dist/skill/openapi-converter.js.map +1 -0
- package/dist/skill/signing.d.ts +5 -0
- package/dist/skill/signing.js +9 -0
- package/dist/skill/signing.js.map +1 -1
- package/dist/skill/store.js +9 -4
- package/dist/skill/store.js.map +1 -1
- package/dist/types.d.ts +43 -2
- package/package.json +3 -1
- package/src/cli.ts +442 -19
- package/src/discovery/openapi.ts +25 -56
- package/src/replay/engine.ts +48 -5
- package/src/skill/apis-guru.ts +169 -0
- package/src/skill/merge.ts +281 -0
- package/src/skill/openapi-converter.ts +434 -0
- package/src/skill/signing.ts +14 -0
- package/src/skill/store.ts +8 -4
- package/src/types.ts +44 -3
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>
|
|
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
|
-
|
|
466
|
-
if (
|
|
467
|
-
|
|
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
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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) {
|
package/src/discovery/openapi.ts
CHANGED
|
@@ -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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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) {
|