@apitap/core 1.4.1 → 1.4.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.
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/cli.ts +144 -1
- package/src/index/reader.ts +65 -0
- package/src/mcp.ts +8 -2
- package/src/native-host.ts +29 -2
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# ApiTap
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@apitap/core)
|
|
4
|
-
[](https://github.com/n1byn1kt/apitap)
|
|
5
5
|
[](./LICENSE)
|
|
6
6
|
|
|
7
7
|
**The MCP server that turns any website into an API — no docs, no SDK, no browser.**
|
|
@@ -393,6 +393,8 @@ All commands support `--json` for machine-readable output.
|
|
|
393
393
|
| `apitap serve <domain>` | Serve a skill file as an MCP server |
|
|
394
394
|
| `apitap inspect <url>` | Discover APIs without saving |
|
|
395
395
|
| `apitap stats` | Show token savings report |
|
|
396
|
+
| `apitap audit` | Audit stored skill files and credentials |
|
|
397
|
+
| `apitap forget <domain>` | Remove skill file and credentials for a domain |
|
|
396
398
|
| `apitap --version` | Print version |
|
|
397
399
|
|
|
398
400
|
### Capture flags
|
|
@@ -414,7 +416,7 @@ All commands support `--json` for machine-readable output.
|
|
|
414
416
|
git clone https://github.com/n1byn1kt/apitap.git
|
|
415
417
|
cd apitap
|
|
416
418
|
npm install
|
|
417
|
-
npm test #
|
|
419
|
+
npm test # 1051 tests, Node built-in test runner
|
|
418
420
|
npm run typecheck # Type checking
|
|
419
421
|
npm run build # Compile to dist/
|
|
420
422
|
npx tsx src/cli.ts capture <url> # Run from source
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@apitap/core",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.2",
|
|
4
4
|
"description": "Intercept web API traffic during browsing. Generate portable skill files so AI agents can call APIs directly instead of scraping.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
package/src/cli.ts
CHANGED
|
@@ -17,11 +17,13 @@ import { buildInspectReport, formatInspectHuman } from './inspect/report.js';
|
|
|
17
17
|
import { generateStatsReport, formatStatsHuman } from './stats/report.js';
|
|
18
18
|
import { detectAntiBot, type AntiBotSignal } from './capture/anti-bot.js';
|
|
19
19
|
import { discover } from './discovery/index.js';
|
|
20
|
+
import { readIndexEntry } from './index/reader.js';
|
|
20
21
|
import { peek } from './read/peek.js';
|
|
21
22
|
import { read } from './read/index.js';
|
|
22
23
|
import { homedir } from 'node:os';
|
|
23
24
|
import { join, resolve, dirname } from 'node:path';
|
|
24
25
|
import { readFileSync } from 'node:fs';
|
|
26
|
+
import { stat, unlink } from 'node:fs/promises';
|
|
25
27
|
import { fileURLToPath } from 'node:url';
|
|
26
28
|
import { createMcpServer } from './mcp.js';
|
|
27
29
|
|
|
@@ -79,6 +81,8 @@ function printUsage(): void {
|
|
|
79
81
|
apitap browse <url> Browse a URL (discover + replay in one step)
|
|
80
82
|
apitap peek <url> Zero-cost triage (HEAD only)
|
|
81
83
|
apitap read <url> Extract content without a browser
|
|
84
|
+
apitap audit Audit stored skill files and credentials
|
|
85
|
+
apitap forget <domain> Remove skill file and credentials for a domain
|
|
82
86
|
apitap stats Show token savings report
|
|
83
87
|
apitap extension install Register native messaging host for Chrome
|
|
84
88
|
|
|
@@ -847,8 +851,17 @@ async function handleDiscover(positional: string[], flags: Record<string, string
|
|
|
847
851
|
|
|
848
852
|
const result = await discover(url);
|
|
849
853
|
|
|
854
|
+
// Look up passive index data for this domain
|
|
855
|
+
let domain: string;
|
|
856
|
+
try {
|
|
857
|
+
domain = new URL(fullDiscoverUrl).hostname;
|
|
858
|
+
} catch {
|
|
859
|
+
domain = url;
|
|
860
|
+
}
|
|
861
|
+
const indexEntry = await readIndexEntry(domain);
|
|
862
|
+
|
|
850
863
|
if (json) {
|
|
851
|
-
console.log(JSON.stringify(result, null, 2));
|
|
864
|
+
console.log(JSON.stringify({ ...result, indexEntry: indexEntry ?? undefined }, null, 2));
|
|
852
865
|
} else {
|
|
853
866
|
// Confidence summary
|
|
854
867
|
const confidenceLabels: Record<string, string> = {
|
|
@@ -895,6 +908,24 @@ async function handleDiscover(positional: string[], flags: Record<string, string
|
|
|
895
908
|
console.log(`\n Skill file: ${result.skillFile.endpoints.length} endpoints predicted`);
|
|
896
909
|
}
|
|
897
910
|
|
|
911
|
+
if (indexEntry) {
|
|
912
|
+
console.log(`\n Passive Index (from browser extension):`);
|
|
913
|
+
console.log(` Domain: ${indexEntry.domain}`);
|
|
914
|
+
console.log(` Total hits: ${indexEntry.totalHits}`);
|
|
915
|
+
console.log(` Endpoints: ${indexEntry.endpoints.length}`);
|
|
916
|
+
console.log(` Promoted: ${indexEntry.promoted ? 'yes' : 'no'}`);
|
|
917
|
+
if (indexEntry.endpoints.length > 0) {
|
|
918
|
+
console.log(` Observed endpoints:`);
|
|
919
|
+
for (const ep of indexEntry.endpoints) {
|
|
920
|
+
const methods = ep.methods.join(', ');
|
|
921
|
+
const auth = ep.authType ? ` [${ep.authType}]` : '';
|
|
922
|
+
const pagination = ep.pagination ? ` (${ep.pagination})` : '';
|
|
923
|
+
const gql = ep.type === 'graphql' ? ' [GraphQL]' : '';
|
|
924
|
+
console.log(` ${methods} ${ep.path} \u2014 ${ep.hits} hits${auth}${pagination}${gql}`);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
898
929
|
if (result.confidence === 'none') {
|
|
899
930
|
console.log(`\n Recommendation: Run \`apitap capture ${url}\` for browser-based discovery`);
|
|
900
931
|
}
|
|
@@ -1050,6 +1081,112 @@ async function handleRead(positional: string[], flags: Record<string, string | b
|
|
|
1050
1081
|
console.log();
|
|
1051
1082
|
}
|
|
1052
1083
|
|
|
1084
|
+
async function handleAudit(flags: Record<string, string | boolean>): Promise<void> {
|
|
1085
|
+
const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
|
|
1086
|
+
const summaries = await listSkillFiles(skillsDir);
|
|
1087
|
+
const machineId = await getEffectiveMachineId();
|
|
1088
|
+
const authManager = new AuthManager(APITAP_DIR, machineId);
|
|
1089
|
+
const authDomains = new Set(await authManager.listDomains());
|
|
1090
|
+
|
|
1091
|
+
// Get skill file last-modified dates
|
|
1092
|
+
const rows: { domain: string; endpoints: number; auth: string; modified: string }[] = [];
|
|
1093
|
+
for (const s of summaries) {
|
|
1094
|
+
let modified = 'unknown';
|
|
1095
|
+
try {
|
|
1096
|
+
const st = await stat(s.skillFile);
|
|
1097
|
+
modified = st.mtime.toISOString().slice(0, 10);
|
|
1098
|
+
} catch {}
|
|
1099
|
+
rows.push({
|
|
1100
|
+
domain: s.domain,
|
|
1101
|
+
endpoints: s.endpointCount,
|
|
1102
|
+
auth: authDomains.has(s.domain) ? 'yes' : 'no',
|
|
1103
|
+
modified,
|
|
1104
|
+
});
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Get auth.enc last-modified
|
|
1108
|
+
let authModified = 'none';
|
|
1109
|
+
try {
|
|
1110
|
+
const authStat = await stat(join(APITAP_DIR, 'auth.enc'));
|
|
1111
|
+
authModified = authStat.mtime.toISOString().slice(0, 10);
|
|
1112
|
+
} catch {}
|
|
1113
|
+
|
|
1114
|
+
if (flags.json === true) {
|
|
1115
|
+
console.log(JSON.stringify({ domains: rows, authFileModified: authModified }, null, 2));
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
if (rows.length === 0) {
|
|
1120
|
+
console.log('\n No skill files found.\n');
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Table header
|
|
1125
|
+
const domW = Math.max(6, ...rows.map(r => r.domain.length));
|
|
1126
|
+
console.log();
|
|
1127
|
+
console.log(` ${'DOMAIN'.padEnd(domW)} ${'ENDPOINTS'.padStart(9)} ${'AUTH'.padEnd(4)} MODIFIED`);
|
|
1128
|
+
console.log(` ${''.padEnd(domW, '-')} ${''.padEnd(9, '-')} ${''.padEnd(4, '-')} ${''.padEnd(10, '-')}`);
|
|
1129
|
+
for (const r of rows) {
|
|
1130
|
+
console.log(` ${r.domain.padEnd(domW)} ${String(r.endpoints).padStart(9)} ${r.auth.padEnd(4)} ${r.modified}`);
|
|
1131
|
+
}
|
|
1132
|
+
console.log();
|
|
1133
|
+
console.log(` auth.enc last modified: ${authModified}`);
|
|
1134
|
+
console.log();
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function handleForget(positional: string[]): Promise<void> {
|
|
1138
|
+
const domain = positional[0];
|
|
1139
|
+
if (!domain) {
|
|
1140
|
+
console.error('Error: Domain required. Usage: apitap forget <domain>');
|
|
1141
|
+
process.exit(1);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(domain) || domain.includes('..')) {
|
|
1145
|
+
console.error('Error: Invalid domain name');
|
|
1146
|
+
process.exit(1);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const skillsDir = SKILLS_DIR || join(APITAP_DIR, 'skills');
|
|
1150
|
+
let skillRemoved = false;
|
|
1151
|
+
let authRemoved = false;
|
|
1152
|
+
let notFound = true;
|
|
1153
|
+
|
|
1154
|
+
// Remove skill file
|
|
1155
|
+
const skillFilePath = join(skillsDir, `${domain}.json`);
|
|
1156
|
+
try {
|
|
1157
|
+
await stat(skillFilePath);
|
|
1158
|
+
notFound = false;
|
|
1159
|
+
await unlink(skillFilePath);
|
|
1160
|
+
skillRemoved = true;
|
|
1161
|
+
} catch {}
|
|
1162
|
+
|
|
1163
|
+
// Remove stored auth
|
|
1164
|
+
try {
|
|
1165
|
+
const machineId = await getEffectiveMachineId();
|
|
1166
|
+
const authManager = new AuthManager(APITAP_DIR, machineId);
|
|
1167
|
+
const hasAuth = await authManager.has(domain);
|
|
1168
|
+
if (hasAuth) {
|
|
1169
|
+
notFound = false;
|
|
1170
|
+
await authManager.clear(domain);
|
|
1171
|
+
authRemoved = true;
|
|
1172
|
+
}
|
|
1173
|
+
} catch (err: any) {
|
|
1174
|
+
if (skillRemoved) {
|
|
1175
|
+
console.error(`Warning: Could not remove auth credentials: ${err.message}`);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
if (notFound) {
|
|
1180
|
+
console.log(`${domain} not found`);
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
const parts: string[] = [];
|
|
1185
|
+
if (skillRemoved) parts.push('skill file');
|
|
1186
|
+
if (authRemoved) parts.push('credentials');
|
|
1187
|
+
console.log(`Forgot ${domain} — ${parts.join(' and ')} removed`);
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1053
1190
|
async function handleExtension(positional: string[], flags: Record<string, string | boolean>): Promise<void> {
|
|
1054
1191
|
const subcommand = positional[0];
|
|
1055
1192
|
|
|
@@ -1150,6 +1287,12 @@ async function main(): Promise<void> {
|
|
|
1150
1287
|
case 'read':
|
|
1151
1288
|
await handleRead(positional, flags);
|
|
1152
1289
|
break;
|
|
1290
|
+
case 'audit':
|
|
1291
|
+
await handleAudit(flags);
|
|
1292
|
+
break;
|
|
1293
|
+
case 'forget':
|
|
1294
|
+
await handleForget(positional);
|
|
1295
|
+
break;
|
|
1153
1296
|
case 'extension':
|
|
1154
1297
|
await handleExtension(positional, flags);
|
|
1155
1298
|
break;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
|
|
5
|
+
// Types re-declared here (extension types can't be imported due to different tsconfig).
|
|
6
|
+
// Keep in sync with extension/src/types.ts IndexFile/IndexEntry/IndexEndpoint.
|
|
7
|
+
export interface IndexFile {
|
|
8
|
+
v: 1;
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
entries: IndexEntry[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface IndexEntry {
|
|
14
|
+
domain: string;
|
|
15
|
+
firstSeen: string;
|
|
16
|
+
lastSeen: string;
|
|
17
|
+
totalHits: number;
|
|
18
|
+
promoted: boolean;
|
|
19
|
+
lastPromoted?: string;
|
|
20
|
+
skillFileSource?: 'extension' | 'cli';
|
|
21
|
+
endpoints: IndexEndpoint[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IndexEndpoint {
|
|
25
|
+
path: string;
|
|
26
|
+
methods: string[];
|
|
27
|
+
authType?: string;
|
|
28
|
+
hasBody: boolean;
|
|
29
|
+
hits: number;
|
|
30
|
+
lastSeen: string;
|
|
31
|
+
pagination?: string;
|
|
32
|
+
type?: 'graphql';
|
|
33
|
+
queryParamNames?: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_APITAP_DIR = path.join(os.homedir(), '.apitap');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the full passive index from disk.
|
|
40
|
+
* Returns null if index.json doesn't exist or is invalid.
|
|
41
|
+
*/
|
|
42
|
+
export async function readIndex(apitapDir: string = DEFAULT_APITAP_DIR): Promise<IndexFile | null> {
|
|
43
|
+
const indexPath = path.join(apitapDir, 'index.json');
|
|
44
|
+
try {
|
|
45
|
+
const raw = await fs.readFile(indexPath, 'utf-8');
|
|
46
|
+
const parsed = JSON.parse(raw);
|
|
47
|
+
if (parsed.v !== 1 || !Array.isArray(parsed.entries)) return null;
|
|
48
|
+
return parsed as IndexFile;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read a single domain's index entry.
|
|
56
|
+
* Returns null if the domain is not in the index.
|
|
57
|
+
*/
|
|
58
|
+
export async function readIndexEntry(
|
|
59
|
+
domain: string,
|
|
60
|
+
apitapDir: string = DEFAULT_APITAP_DIR,
|
|
61
|
+
): Promise<IndexEntry | null> {
|
|
62
|
+
const index = await readIndex(apitapDir);
|
|
63
|
+
if (!index) return null;
|
|
64
|
+
return index.entries.find(e => e.domain === domain) ?? null;
|
|
65
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { deriveSigningKey } from './auth/crypto.js';
|
|
|
11
11
|
import { requestAuth } from './auth/handoff.js';
|
|
12
12
|
import { CaptureSession } from './capture/session.js';
|
|
13
13
|
import { discover } from './discovery/index.js';
|
|
14
|
+
import { readIndexEntry } from './index/reader.js';
|
|
14
15
|
import { SessionCache } from './orchestration/cache.js';
|
|
15
16
|
import { peek } from './read/peek.js';
|
|
16
17
|
import { read } from './read/index.js';
|
|
@@ -139,6 +140,11 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
139
140
|
}
|
|
140
141
|
const result = await discover(url);
|
|
141
142
|
|
|
143
|
+
// Look up passive index data for this domain
|
|
144
|
+
let domain: string;
|
|
145
|
+
try { domain = new URL(url).hostname; } catch { domain = url; }
|
|
146
|
+
const indexEntry = await readIndexEntry(domain);
|
|
147
|
+
|
|
142
148
|
// If we got a skill file, sign and save it automatically
|
|
143
149
|
if (result.skillFile && (result.confidence === 'high' || result.confidence === 'medium')) {
|
|
144
150
|
const { writeSkillFile } = await import('./skill/store.js');
|
|
@@ -147,10 +153,10 @@ export function createMcpServer(options: McpServerOptions = {}): McpServer {
|
|
|
147
153
|
const sigKey = deriveSigningKey(machineId);
|
|
148
154
|
result.skillFile = signSkillFile(result.skillFile, sigKey);
|
|
149
155
|
const path = await writeSkillFile(result.skillFile, skillsDir);
|
|
150
|
-
return wrapExternalContent({ ...result, savedTo: path }, 'apitap_discover');
|
|
156
|
+
return wrapExternalContent({ ...result, savedTo: path, indexEntry: indexEntry ?? undefined }, 'apitap_discover');
|
|
151
157
|
}
|
|
152
158
|
|
|
153
|
-
return wrapExternalContent(result, 'apitap_discover');
|
|
159
|
+
return wrapExternalContent({ ...result, indexEntry: indexEntry ?? undefined }, 'apitap_discover');
|
|
154
160
|
} catch (err: any) {
|
|
155
161
|
return {
|
|
156
162
|
content: [{ type: 'text' as const, text: `Discovery failed: ${err.message}` }],
|
package/src/native-host.ts
CHANGED
|
@@ -27,10 +27,11 @@ async function signSkillJson(skillJson: string): Promise<string> {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
export interface NativeRequest {
|
|
30
|
-
action: 'save_skill' | 'save_batch' | 'ping' | 'capture_request';
|
|
30
|
+
action: 'save_skill' | 'save_batch' | 'ping' | 'capture_request' | 'save_index';
|
|
31
31
|
domain?: string;
|
|
32
32
|
skillJson?: string;
|
|
33
33
|
skills?: Array<{ domain: string; skillJson: string }>;
|
|
34
|
+
indexJson?: string;
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
export interface NativeResponse {
|
|
@@ -115,6 +116,32 @@ export async function handleNativeMessage(
|
|
|
115
116
|
return { success: true, paths };
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
if (request.action === 'save_index') {
|
|
120
|
+
if (!request.indexJson) {
|
|
121
|
+
return { success: false, error: 'Missing indexJson' };
|
|
122
|
+
}
|
|
123
|
+
if (request.indexJson.length > 5 * 1024 * 1024) {
|
|
124
|
+
return { success: false, error: 'Index too large (max 5MB)' };
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
JSON.parse(request.indexJson);
|
|
128
|
+
} catch {
|
|
129
|
+
return { success: false, error: 'Invalid JSON in indexJson' };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// index.json lives in ~/.apitap/ (parent of skills dir)
|
|
133
|
+
const apitapDir = path.dirname(skillsDir);
|
|
134
|
+
await fs.mkdir(apitapDir, { recursive: true });
|
|
135
|
+
const indexPath = path.join(apitapDir, 'index.json');
|
|
136
|
+
|
|
137
|
+
// Atomic write: temp file + rename
|
|
138
|
+
const tmpPath = indexPath + '.tmp.' + process.pid;
|
|
139
|
+
await fs.writeFile(tmpPath, request.indexJson, { mode: 0o600 });
|
|
140
|
+
await fs.rename(tmpPath, indexPath);
|
|
141
|
+
|
|
142
|
+
return { success: true, path: indexPath };
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
return { success: false, error: `Unknown action: ${request.action}` };
|
|
119
146
|
} catch (err) {
|
|
120
147
|
return { success: false, error: String(err) };
|
|
@@ -124,7 +151,7 @@ export async function handleNativeMessage(
|
|
|
124
151
|
// --- Relay handler ---
|
|
125
152
|
|
|
126
153
|
// Actions handled locally by the native host (filesystem operations)
|
|
127
|
-
const LOCAL_ACTIONS = new Set(['save_skill', 'save_batch', 'ping']);
|
|
154
|
+
const LOCAL_ACTIONS = new Set(['save_skill', 'save_batch', 'ping', 'save_index']);
|
|
128
155
|
|
|
129
156
|
// Actions relayed to the extension (browser operations)
|
|
130
157
|
const EXTENSION_ACTIONS = new Set(['capture_request']);
|