@sorit/cli 0.1.0 → 0.1.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/README.md +6 -4
- package/package.json +7 -5
- package/src/{cli.mjs → cli.ts} +58 -37
- package/src/client.ts +104 -0
- package/src/contracts.ts +82 -0
- package/src/output.ts +122 -0
- package/src/types.ts +29 -0
- package/tsconfig.json +13 -0
- package/src/client.mjs +0 -68
- package/src/output.mjs +0 -120
package/README.md
CHANGED
|
@@ -14,11 +14,13 @@ sorit get '<REQUEST_ID>' --wait
|
|
|
14
14
|
|
|
15
15
|
Requires Bun 1.2+. The API server defaults to production at `https://sorit.dev`; no server configuration is needed. Create an API key at https://sorit.dev/app/keys and set `SORIT_API_KEY`. For local development, override the origin with an environment variable, for example `SORIT_URL=http://localhost:3010 sorit tools`. Failures exit with code 1. `run --wait` writes the accepted request ID to stderr before polling, so interrupted waits can be resumed.
|
|
16
16
|
|
|
17
|
+
The CLI is written in TypeScript and runs directly on Bun without a separate build step. Source types cover each command's result, while HTTP response validation checks actual server data. From the repository root, check it independently with `pnpm exec tsc -p packages/cli/tsconfig.json`.
|
|
18
|
+
|
|
17
19
|
## Output
|
|
18
20
|
|
|
19
|
-
Commands display readable summaries by default: API names and prices, schema details, request status and business data, or balance. `sorit balance` shows the USD equivalent and credit amount, without prices or payment settings. Dollar values retain up to six decimal places so small credit balances are not rounded away.
|
|
21
|
+
Commands display readable summaries by default: platform tools and their API counts, API names and prices, schema details, request status and business data, or balance. `inspect` handles localized titles/descriptions and shows the POST path plus input and output schemas. `sorit balance` shows the USD equivalent and credit amount, without prices or payment settings. Dollar values retain up to six decimal places so small credit balances are not rounded away.
|
|
20
22
|
|
|
21
|
-
For scripts and agents, append `--json` to any command. Successful responses go to stdout; HTTP and command errors go to stderr.
|
|
23
|
+
For scripts and agents, append `--json` to any command. Successful responses go to stdout; HTTP and command errors go to stderr. A successfully retrieved terminal failed/rejected request is also written to stdout in JSON mode, with exit code 1; human mode writes that failure to stderr. `balance --json` returns only `balance` (credits), `creditsPerUsd`, `balanceUsd` and `currency`. `tools --json` returns platform groups (`id`, `platform`, `name`, `apiCount`). `discover --json` returns the filtered data-API listing with `total`, `page`, `pageSize` and `apis`. Inspection and request reads retain their REST JSON objects, including localized descriptions and normalized business data. Pending requests label the reserved credits; completed requests show net spending after refunds.
|
|
22
24
|
|
|
23
25
|
```sh
|
|
24
26
|
sorit balance
|
|
@@ -40,8 +42,8 @@ sorit get '<REQUEST_ID>' --wait --json
|
|
|
40
42
|
|
|
41
43
|
Run `sorit --help` for the command list. Each command has its own arguments, options and examples: `sorit run --help` or `sorit help run`. Help and version commands work offline without credentials; `--version`, `-V` and `-v` print the version. Discovery and inspection do not require an API key. `run`, `get` and `balance` do.
|
|
42
44
|
|
|
43
|
-
Use `--search` for keyword filtering.
|
|
45
|
+
Use `--search` for keyword filtering in English or Chinese. `tools` groups available APIs by platform; `discover --tool twitter` filters that platform's APIs. Both read the server's complete available-only `/api/v1/apis` snapshot; the CLI performs tool/keyword filtering and 24-item pagination because the current server catalog has no tool/page query contract. Discovery never requests disabled items. Malformed or non-available catalog entries fail validation instead of silently appearing in search. `--status` accepts only `available`; unavailable APIs are managed in the admin console. Discovery supports `--page` from 1 to 10000, and reports an empty page without inventing results. `run` and `get` accept `--timeout` in milliseconds with `--wait` (default 120000). Unsupported command options fail before making an API request.
|
|
44
46
|
|
|
45
|
-
Calls use your account's credits. Reuse the same idempotency key and input for retries. A polling timeout does not cancel the request. Keep API keys in the environment.
|
|
47
|
+
Calls use your account's credits. `run` sends flat input to `POST /api/v1/run/{api-id}`; `get` reads `GET /api/v1/requests/{request-id}`. Reuse the same idempotency key and input for retries. Paid submissions are never automatically retried; a timeout or invalid submission response may mean the request was accepted, so preserve its identity. A polling timeout does not cancel the request and reports the exact resume command. Keep API keys in the environment; discovery and inspection do not transmit them.
|
|
46
48
|
|
|
47
49
|
Agent instructions: https://sorit.dev/SKILL.md. REST reference: https://sorit.dev/en/docs/api/jobs.
|
package/package.json
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sorit/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Discover and call Sorit data APIs from your terminal or AI agent",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
|
-
"sorit": "src/cli.
|
|
7
|
+
"sorit": "src/cli.ts"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
|
-
"src
|
|
11
|
-
"
|
|
12
|
-
"src/output.mjs",
|
|
10
|
+
"src/*.ts",
|
|
11
|
+
"tsconfig.json",
|
|
13
12
|
"README.md"
|
|
14
13
|
],
|
|
15
14
|
"engines": {
|
|
@@ -20,5 +19,8 @@
|
|
|
20
19
|
"access": "public",
|
|
21
20
|
"registry": "https://registry.npmjs.org",
|
|
22
21
|
"@sorit:registry": "https://registry.npmjs.org"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"typecheck": "tsc --noEmit"
|
|
23
25
|
}
|
|
24
26
|
}
|
package/src/{cli.mjs → cli.ts}
RENAMED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { parseArgs } from "node:util";
|
|
4
|
-
import { SoritClient } from "./client
|
|
5
|
-
import { balanceSummary, formatError, formatResult } from "./output
|
|
4
|
+
import { SoritClient, SoritError } from "./client";
|
|
5
|
+
import { balanceSummary, formatError, formatResult } from "./output";
|
|
6
|
+
import { isObject } from "./contracts";
|
|
7
|
+
import type { CommandName, CommandResults } from "./types";
|
|
6
8
|
|
|
7
|
-
const
|
|
9
|
+
const metadata: unknown = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
10
|
+
if (!isObject(metadata) || typeof metadata.version !== "string") throw new Error("Invalid CLI package metadata.");
|
|
11
|
+
const version = metadata.version;
|
|
8
12
|
const flags = {
|
|
9
13
|
search: { syntax: "--search <text>", description: "Filter by keywords." },
|
|
10
14
|
status: { syntax: "--status <status>", description: "Only available is supported; unavailable APIs are excluded." },
|
|
@@ -15,7 +19,9 @@ const flags = {
|
|
|
15
19
|
wait: { syntax: "--wait", description: "Poll until the request finishes." },
|
|
16
20
|
timeout: { syntax: "--timeout <milliseconds>", description: "Polling time limit with --wait. Default: 120000." },
|
|
17
21
|
};
|
|
18
|
-
|
|
22
|
+
type Flag = keyof typeof flags;
|
|
23
|
+
type CommandSpec = { description: string; usage: string; options: Flag[]; notes: string; examples: string[]; argument?: [string, string] };
|
|
24
|
+
const commands: Record<CommandName, CommandSpec> = {
|
|
19
25
|
tools: {
|
|
20
26
|
description: "List data tools and platforms.",
|
|
21
27
|
usage: "tools [options]", options: ["search", "status"],
|
|
@@ -39,11 +45,11 @@ const commands = {
|
|
|
39
45
|
description: "Create a paid API request and optionally wait for results.",
|
|
40
46
|
usage: "run <api-id> --input <json|@file> --idempotency-key <key> [options]",
|
|
41
47
|
options: ["input", "idempotency-key", "wait", "timeout"],
|
|
42
|
-
argument: ["<api-id>", "Registered API ID returned by discover
|
|
48
|
+
argument: ["<api-id>", "Registered Sorit data API ID returned by discover."],
|
|
43
49
|
notes: "Requires SORIT_API_KEY and account credits. Use the same key and input for retries.\nA wait timeout does not cancel the request; resume with sorit get <request-id> --wait.",
|
|
44
50
|
examples: [
|
|
45
51
|
"sorit run <api-id> --input @input.json --idempotency-key research-001 --wait",
|
|
46
|
-
`sorit run <api-id> --input '{"
|
|
52
|
+
`sorit run <api-id> --input '{"limit":10}' --idempotency-key research-002`,
|
|
47
53
|
],
|
|
48
54
|
},
|
|
49
55
|
get: {
|
|
@@ -59,22 +65,22 @@ const commands = {
|
|
|
59
65
|
notes: "Requires SORIT_API_KEY. Shows USD equivalent and credits; no checkout configuration.", examples: ["sorit balance", "sorit balance --json"],
|
|
60
66
|
},
|
|
61
67
|
};
|
|
62
|
-
const rows = entries => entries.map(([label, description]) => ` ${label.padEnd(30)} ${description}`).join("\n");
|
|
63
|
-
const section = (title, body) => `${title}:\n${body}`;
|
|
64
|
-
const globalOptions = [
|
|
68
|
+
const rows = (entries: ReadonlyArray<readonly [string, string]>) => entries.map(([label, description]) => ` ${label.padEnd(30)} ${description}`).join("\n");
|
|
69
|
+
const section = (title: string, body: string) => `${title}:\n${body}`;
|
|
70
|
+
const globalOptions: [string, string][] = [
|
|
65
71
|
["-h, --help", "Show help for this command."],
|
|
66
72
|
["-V, --version", "Show the version number (-v also supported)."],
|
|
67
73
|
["--json", "Output structured JSON for scripts and agents."],
|
|
68
74
|
];
|
|
69
|
-
function help(command) {
|
|
70
|
-
const spec = commands[command];
|
|
75
|
+
function help(command?: CommandName) {
|
|
76
|
+
const spec = command ? commands[command] : undefined;
|
|
71
77
|
return [
|
|
72
78
|
section("Usage", ` sorit ${spec?.usage ?? "<command> [options]"}`),
|
|
73
79
|
section("Version", ` ${version}`),
|
|
74
80
|
section("Description", ` ${spec?.description ?? "Sorit CLI — discover, inspect and run data APIs."}`),
|
|
75
81
|
...(spec?.argument ? [section("Arguments", rows([spec.argument]))] : []),
|
|
76
|
-
section("Options", rows([...(spec?.options ?? []).map(name => [flags[name].syntax, flags[name].description]), ...globalOptions])),
|
|
77
|
-
...(!spec ? [section("Commands", rows([...Object.entries(commands).map(([name, value]) => [name, value.description]), ["help [command]", "Show help for a command."]]))] : []),
|
|
82
|
+
section("Options", rows([...(spec?.options ?? []).map((name): [string, string] => [flags[name].syntax, flags[name].description]), ...globalOptions])),
|
|
83
|
+
...(!spec ? [section("Commands", rows([...Object.entries(commands).map(([name, value]): [string, string] => [name, value.description]), ["help [command]", "Show help for a command."]]))] : []),
|
|
78
84
|
section("Environment", rows([
|
|
79
85
|
["SORIT_API_KEY", "API key for run, get and balance."],
|
|
80
86
|
["SORIT_URL", "Optional origin override for local deployments."],
|
|
@@ -86,6 +92,14 @@ function help(command) {
|
|
|
86
92
|
].join("\n\n");
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
function isCommand(value: string | undefined): value is CommandName { return Boolean(value && Object.hasOwn(commands, value)); }
|
|
96
|
+
function printResult<K extends CommandName>(command: K, result: CommandResults[K]) {
|
|
97
|
+
const failed = "status" in result && (result.status === "failed" || result.status === "rejected");
|
|
98
|
+
const write = failed && !jsonOutput ? console.error : console.log;
|
|
99
|
+
write(jsonOutput ? JSON.stringify(result, null, 2) : formatResult(command, result));
|
|
100
|
+
if (failed) process.exitCode = 1;
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
let jsonOutput = process.argv.slice(2).includes("--json");
|
|
90
104
|
try {
|
|
91
105
|
const { values, positionals } = parseArgs({ args: process.argv.slice(2).map(arg => arg === "-v" ? "-V" : arg), allowPositionals: true, options: {
|
|
@@ -97,16 +111,19 @@ try {
|
|
|
97
111
|
} });
|
|
98
112
|
jsonOutput = Boolean(values.json);
|
|
99
113
|
const explicitHelp = positionals[0] === "help";
|
|
100
|
-
const [
|
|
101
|
-
|
|
102
|
-
|
|
114
|
+
const [rawCommand, id] = explicitHelp ? positionals.slice(1) : positionals;
|
|
115
|
+
if (rawCommand && !isCommand(rawCommand)) throw new Error(`Unknown command "${rawCommand}". Use sorit --help.`);
|
|
116
|
+
const command = isCommand(rawCommand) ? rawCommand : undefined;
|
|
117
|
+
const spec = command ? commands[command] : undefined;
|
|
103
118
|
for (const name of Object.keys(values)) {
|
|
104
|
-
if (!["version", "help", "json"].includes(name) && !spec?.options.
|
|
119
|
+
if (!["version", "help", "json"].includes(name) && !spec?.options.some(option => option === name)) throw new Error(`--${name} is not supported here. Use sorit ${command ? `${command} ` : ""}--help.`);
|
|
105
120
|
}
|
|
106
121
|
if (explicitHelp && positionals.length > 2) throw new Error("Usage: sorit help [command].");
|
|
107
122
|
if (values.version) console.log(version);
|
|
108
123
|
else if (values.help || explicitHelp || !command) console.log(help(command));
|
|
109
124
|
else {
|
|
125
|
+
const spec = commands[command];
|
|
126
|
+
const target = id ?? "";
|
|
110
127
|
if (positionals.length !== (spec.argument ? 2 : 1) || spec.argument && !id?.trim()) throw new Error(`Usage: sorit ${spec.usage}. See sorit ${command} --help.`);
|
|
111
128
|
if (values.timeout !== undefined && !values.wait) throw new Error("--timeout requires --wait.");
|
|
112
129
|
const timeoutMs = Number(values.timeout ?? 120000);
|
|
@@ -115,29 +132,33 @@ try {
|
|
|
115
132
|
if (values.status !== undefined && values.status !== "available") throw new Error("Search only returns available APIs. --status must be available.");
|
|
116
133
|
const client = new SoritClient({ baseUrl: process.env.SORIT_URL?.trim() || undefined, apiKey: process.env.SORIT_API_KEY ?? process.env.SELLERTOOL_API_KEY });
|
|
117
134
|
const filters = { q: values.search, page: values.page, status: values.status };
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
switch (command) {
|
|
136
|
+
case "balance": printResult(command, balanceSummary(await client.request("/api/credits", { authenticated: true }))); break;
|
|
137
|
+
case "tools": printResult(command, await client.tools(filters)); break;
|
|
138
|
+
case "discover": printResult(command, await client.endpoints({ ...filters, tool: values.tool })); break;
|
|
139
|
+
case "inspect": printResult(command, await client.describe(target)); break;
|
|
140
|
+
case "get": printResult(command, values.wait ? await client.wait(target, { timeoutMs }) : await client.get(target)); break;
|
|
141
|
+
case "run": {
|
|
142
|
+
if (!values.input) throw new Error("Supply --input @input.json or a JSON object string. See sorit run --help.");
|
|
143
|
+
let source: string;
|
|
144
|
+
try { source = values.input.startsWith("@") ? await readFile(values.input.slice(1), "utf8") : values.input; }
|
|
145
|
+
catch { throw new Error(`Cannot read input file ${values.input.slice(1)}.`); }
|
|
146
|
+
let input: unknown;
|
|
147
|
+
try { input = JSON.parse(source); }
|
|
148
|
+
catch { throw new Error("Input is not valid JSON. Use --input @input.json or a JSON object string."); }
|
|
149
|
+
if (!isObject(input)) throw new Error("Input must be a JSON object.");
|
|
150
|
+
let result = await client.create(target, input, { idempotencyKey: values["idempotency-key"] });
|
|
151
|
+
if (values.wait && ["queued", "running"].includes(result.status)) {
|
|
152
|
+
// Print the accepted ID before polling so interrupted calls can be resumed.
|
|
153
|
+
console.error(`Accepted request_id: ${result.request_id}`);
|
|
154
|
+
result = await client.wait(result.request_id, { timeoutMs });
|
|
155
|
+
}
|
|
156
|
+
printResult(command, result); break;
|
|
132
157
|
}
|
|
133
158
|
}
|
|
134
|
-
if (command === "get") result = values.wait ? await client.wait(id, { timeoutMs }) : await client.get(id);
|
|
135
|
-
const failed = ["failed", "rejected"].includes(result?.status);
|
|
136
|
-
const write = failed && !jsonOutput ? console.error : console.log;
|
|
137
|
-
write(jsonOutput ? JSON.stringify(result, null, 2) : formatResult(command, result));
|
|
138
|
-
if (failed) process.exitCode = 1;
|
|
139
159
|
}
|
|
140
160
|
} catch (error) {
|
|
141
|
-
|
|
161
|
+
const result = error instanceof SoritError ? error.result : null;
|
|
162
|
+
console.error(jsonOutput ? JSON.stringify(result ?? { error: error instanceof Error ? error.message : "Command failed." }, null, 2) : formatError(error));
|
|
142
163
|
process.exitCode = 1;
|
|
143
164
|
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/** Internal HTTP transport for the Sorit CLI. */
|
|
2
|
+
import { isObject, validateApi, validateCatalog, validateRequest, identifier, filters, platformId, searchTerms, apiSearchText, catalogTools } from "./contracts";
|
|
3
|
+
import type { ApiDiscovery, ApiInspection, CatalogFilters, SoritRequest, ToolListing } from "./types";
|
|
4
|
+
type RequestOptions = { method?: string; body?: unknown; idempotencyKey?: string; authenticated?: boolean; timeoutMs?: number };
|
|
5
|
+
export class SoritError extends Error {
|
|
6
|
+
status: number;
|
|
7
|
+
result: unknown;
|
|
8
|
+
constructor(message: string, { status = 0, result = null }: { status?: number; result?: unknown } = {}) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "SoritError";
|
|
11
|
+
this.status = status;
|
|
12
|
+
this.result = result;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class SoritClient {
|
|
17
|
+
baseUrl: string;
|
|
18
|
+
apiKey?: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
constructor({ baseUrl = "https://sorit.dev", apiKey, timeoutMs = 30_000 }: { baseUrl?: string; apiKey?: string; timeoutMs?: number } = {}) {
|
|
21
|
+
const url = new URL(baseUrl);
|
|
22
|
+
if (!(["https:"].includes(url.protocol) || url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error("Use HTTPS, or HTTP on localhost.");
|
|
23
|
+
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("baseUrl must be an origin.");
|
|
24
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("timeoutMs must be positive.");
|
|
25
|
+
this.baseUrl = url.origin;
|
|
26
|
+
this.apiKey = apiKey;
|
|
27
|
+
this.timeoutMs = timeoutMs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async request(path: string, { method = "GET", body, idempotencyKey, authenticated = false, timeoutMs = this.timeoutMs }: RequestOptions = {}): Promise<unknown> {
|
|
31
|
+
if (!path.startsWith("/") || path.startsWith("//")) throw new Error("Use a Sorit API path.");
|
|
32
|
+
if (authenticated && !this.apiKey) throw new Error(`Set SORIT_API_KEY. Create an API key at ${this.baseUrl}/app/keys.`);
|
|
33
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
34
|
+
if (authenticated) headers.Authorization = `Bearer ${this.apiKey}`;
|
|
35
|
+
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
36
|
+
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
37
|
+
// Never forward a bearer key through a redirect, or automatically retry a paid POST.
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetch(`${this.baseUrl}${path}`, {
|
|
41
|
+
method, headers, redirect: "error", signal: AbortSignal.timeout(timeoutMs),
|
|
42
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
43
|
+
});
|
|
44
|
+
} catch (error) {
|
|
45
|
+
const timeout = error instanceof Error && ["TimeoutError", "AbortError"].includes(error.name);
|
|
46
|
+
throw new SoritError(`${timeout ? "Request timed out" : "Could not reach Sorit"} at ${this.baseUrl}.${method === "POST" ? " Submission is unconfirmed; retry with the same API ID, input and idempotency key." : ""}`);
|
|
47
|
+
}
|
|
48
|
+
let result: unknown;
|
|
49
|
+
try { result = await response.json(); }
|
|
50
|
+
catch { throw new SoritError(`Sorit returned a non-JSON response (HTTP ${response.status}).${method === "POST" ? " Submission is unconfirmed; retry with the same API ID, input and idempotency key." : ""}`, { status: response.status }); }
|
|
51
|
+
if (!isObject(result)) throw new SoritError(`Sorit returned an invalid JSON response.${method === "POST" ? " Submission is unconfirmed; retry with the same API ID, input and idempotency key." : ""}`, { status: response.status });
|
|
52
|
+
if (!response.ok) throw new SoritError(typeof result.error === "string" ? result.error : isObject(result.error) && typeof result.error.message === "string" ? result.error.message : "Sorit request failed.", { status: response.status, result });
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async tools(input: CatalogFilters = {}): Promise<ToolListing> {
|
|
57
|
+
const { q } = filters(input), catalog = validateCatalog(await this.request("/api/v1/apis"));
|
|
58
|
+
const tools = catalogTools(catalog.apis, q);
|
|
59
|
+
return { schemaVersion: 1, layer: "tools", total: tools.length, tools };
|
|
60
|
+
}
|
|
61
|
+
async endpoints(input: CatalogFilters = {}): Promise<ApiDiscovery> {
|
|
62
|
+
const { tool, q, page } = filters(input), catalog = validateCatalog(await this.request("/api/v1/apis"));
|
|
63
|
+
// The public catalog currently has no server tool/page filters. Apply these to its complete available snapshot.
|
|
64
|
+
const terms = searchTerms(q), pageSize = 24;
|
|
65
|
+
const apis = catalog.apis.filter(api => (!tool || platformId(api.platform) === platformId(tool)) && terms.every(term => apiSearchText(api).includes(term)));
|
|
66
|
+
return { ...catalog, total: apis.length, page, pageSize, apis: apis.slice((page - 1) * pageSize, page * pageSize),
|
|
67
|
+
categories: [...new Set(catalog.apis.map(api => api.category))].map(id => ({ id, count: apis.filter(api => api.category === id).length })) };
|
|
68
|
+
}
|
|
69
|
+
async describe(apiId: string): Promise<ApiInspection> { return validateApi(await this.request(`/api/v1/apis/${encodeURIComponent(identifier(apiId, "API ID"))}`), true); }
|
|
70
|
+
create(apiId: string, input: unknown, { idempotencyKey }: { idempotencyKey?: string } = {}): Promise<SoritRequest> {
|
|
71
|
+
identifier(apiId, "API ID");
|
|
72
|
+
if (!isObject(input)) throw new Error("Input must be a JSON object.");
|
|
73
|
+
if (!idempotencyKey || !/^[A-Za-z0-9._:-]{1,128}$/.test(idempotencyKey)) throw new Error("Supply a unique idempotencyKey (1–128 letters, numbers, dots, underscores, colons or hyphens); reuse it for retries.");
|
|
74
|
+
return this.request(`/api/v1/run/${encodeURIComponent(apiId)}`, { method: "POST", authenticated: true, body: input, idempotencyKey }).then(result => {
|
|
75
|
+
try { return validateRequest(result); }
|
|
76
|
+
catch { throw new SoritError(`Sorit returned an invalid submission response${isObject(result) && typeof result.request_id === "string" ? ` for request_id ${result.request_id}` : ""}. Retry with the same API ID, input and idempotency key.`, { status: 200 }); }
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
async get(requestId: string, { timeoutMs }: { timeoutMs?: number } = {}): Promise<SoritRequest> {
|
|
80
|
+
identifier(requestId, "request ID");
|
|
81
|
+
return validateRequest(await this.request(`/api/v1/requests/${encodeURIComponent(requestId)}`, { authenticated: true, timeoutMs }), requestId);
|
|
82
|
+
}
|
|
83
|
+
async wait(requestId: string, { intervalMs = 1500, timeoutMs = 120_000 }: { intervalMs?: number; timeoutMs?: number } = {}): Promise<SoritRequest> {
|
|
84
|
+
if (!Number.isFinite(intervalMs) || intervalMs < 100 || !Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Use intervalMs >= 100 and timeoutMs > 0.");
|
|
85
|
+
const deadline = Date.now() + timeoutMs;
|
|
86
|
+
const timedOut = () => new SoritError(`Polling timed out for request_id ${requestId}. Resume with sorit get ${requestId} --wait; the request may still be running.`);
|
|
87
|
+
while (Date.now() < deadline) {
|
|
88
|
+
let result;
|
|
89
|
+
try { result = await this.get(requestId, { timeoutMs: Math.max(1, Math.min(this.timeoutMs, deadline - Date.now())) }); }
|
|
90
|
+
catch (error) {
|
|
91
|
+
const status = error instanceof SoritError ? error.status : 0;
|
|
92
|
+
const readUnconfirmed = !status || status >= 200 && status < 300;
|
|
93
|
+
if (Date.now() >= deadline && readUnconfirmed) throw timedOut();
|
|
94
|
+
if (readUnconfirmed) throw new SoritError(`${error instanceof Error ? error.message : "Could not read request."} Resume with sorit get ${requestId} --wait.`);
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
if (["succeeded", "failed", "rejected"].includes(result.status)) return result;
|
|
98
|
+
if (!["queued", "running"].includes(result.status)) throw new SoritError("Unexpected request status.", { result });
|
|
99
|
+
const remaining = deadline - Date.now();
|
|
100
|
+
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(intervalMs, remaining)));
|
|
101
|
+
}
|
|
102
|
+
throw timedOut();
|
|
103
|
+
}
|
|
104
|
+
}
|
package/src/contracts.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Runtime validation at the HTTP boundary complements compile-time command/result types. */
|
|
2
|
+
import type { ApiCatalog, ApiInspection, CatalogFilters, DataApi, JsonObject, LocalizedText, SoritRequest, Tool } from "./types";
|
|
3
|
+
|
|
4
|
+
export const isObject = (value: unknown): value is JsonObject => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5
|
+
export function localized(value: unknown): string {
|
|
6
|
+
if (typeof value === "string") return value;
|
|
7
|
+
if (!isObject(value)) return "";
|
|
8
|
+
return [value.en, value.zh].find((item): item is string => typeof item === "string" && Boolean(item.trim())) ?? "";
|
|
9
|
+
}
|
|
10
|
+
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
11
|
+
const count = (value: unknown): value is number => finite(value) && Number.isSafeInteger(value);
|
|
12
|
+
const validId = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(value);
|
|
13
|
+
const localizedText = (value: unknown): value is LocalizedText => typeof value === "string" || isObject(value) &&
|
|
14
|
+
(typeof value.en === "string" || typeof value.zh === "string") && (value.en === undefined || typeof value.en === "string") && (value.zh === undefined || typeof value.zh === "string");
|
|
15
|
+
function invalid(kind: string): never { throw new Error(`Sorit returned an invalid ${kind} response.`); }
|
|
16
|
+
function isApi(api: unknown): api is DataApi {
|
|
17
|
+
return isObject(api) && validId(api.id) && typeof api.platform === "string" && Boolean(api.platform) &&
|
|
18
|
+
typeof api.category === "string" && localizedText(api.title) && Boolean(localized(api.title)) && localizedText(api.description) &&
|
|
19
|
+
api.status === "available" && isObject(api.pricing) && finite(api.pricing.credits) && finite(api.pricing.amount) &&
|
|
20
|
+
api.pricing.currency === "USD" && (api.pricing.unit === "request" || api.pricing.unit === "record") && api.pricing.confirmed === true && api.pricing.failureRefund === true &&
|
|
21
|
+
isObject(api.limits) && count(api.limits.maxItems) && typeof api.path === "string";
|
|
22
|
+
}
|
|
23
|
+
function isInspection(api: unknown): api is ApiInspection {
|
|
24
|
+
return isApi(api) && "inputSchema" in api && isObject(api.inputSchema) && "outputSchema" in api && isObject(api.outputSchema) &&
|
|
25
|
+
"requestProtocol" in api && isObject(api.requestProtocol) && "invocation" in api && isObject(api.invocation) && api.invocation.method === "POST" &&
|
|
26
|
+
api.invocation.path === `/api/v1/run/${api.id}` && typeof api.invocation.authentication === "string" && typeof api.invocation.idempotencyHeader === "string" && typeof api.invocation.resultPath === "string";
|
|
27
|
+
}
|
|
28
|
+
export function validateApi(api: unknown, inspection: true): ApiInspection;
|
|
29
|
+
export function validateApi(api: unknown, inspection?: false): DataApi;
|
|
30
|
+
export function validateApi(api: unknown, inspection = false): DataApi | ApiInspection {
|
|
31
|
+
if (inspection) { if (!isInspection(api)) invalid("API inspection"); return api; }
|
|
32
|
+
if (!isApi(api)) invalid("data API");
|
|
33
|
+
return api;
|
|
34
|
+
}
|
|
35
|
+
function isCatalog(result: unknown): result is ApiCatalog {
|
|
36
|
+
return isObject(result) && count(result.schemaVersion) && result.layer === "data_apis" && Array.isArray(result.apis) && result.apis.every(isApi) &&
|
|
37
|
+
count(result.total) && result.total === result.apis.length && count(result.catalogTotal) && result.catalogTotal >= result.total &&
|
|
38
|
+
Array.isArray(result.categories) && result.categories.every(category => isObject(category) && typeof category.id === "string" && count(category.count));
|
|
39
|
+
}
|
|
40
|
+
export function validateCatalog(result: unknown): ApiCatalog {
|
|
41
|
+
if (!isCatalog(result) || new Set(result.apis.map(api => api.id)).size !== result.apis.length) invalid("data catalog");
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
function isRequest(result: unknown): result is SoritRequest {
|
|
45
|
+
if (!isObject(result) || !validId(result.request_id) || !(result.api_id === null || validId(result.api_id)) ||
|
|
46
|
+
typeof result.status !== "string" || !["queued", "running", "succeeded", "failed", "rejected"].includes(result.status) || !isObject(result.credits) ||
|
|
47
|
+
!finite(result.credits.charged) || !finite(result.credits.refunded) || result.credits.refunded > result.credits.charged || !Object.hasOwn(result, "data") || !Object.hasOwn(result, "error")) return false;
|
|
48
|
+
if (!["failed", "rejected"].includes(result.status)) return result.error === null;
|
|
49
|
+
const error = result.error;
|
|
50
|
+
return isObject(error) && typeof error.code === "string" && typeof error.message === "string" && (error.fields === undefined || Array.isArray(error.fields) &&
|
|
51
|
+
error.fields.every(field => isObject(field) && Array.isArray(field.path) && field.path.every(part => typeof part === "string" || typeof part === "number") && typeof field.message === "string"));
|
|
52
|
+
}
|
|
53
|
+
export function validateRequest(result: unknown, expectedId?: string): SoritRequest {
|
|
54
|
+
if (!isRequest(result) || expectedId !== undefined && result.request_id !== expectedId) invalid("request");
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
export function identifier(value: unknown, label: string): string {
|
|
58
|
+
if (!validId(value)) throw new Error(`Supply a valid ${label}.`);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
export function filters({ q = "", tool = "", page = 1, status = "available" }: CatalogFilters = {}) {
|
|
62
|
+
const pageNumber = Number(page);
|
|
63
|
+
if (typeof q !== "string" || q.length > 200) throw new Error("Search must be at most 200 characters.");
|
|
64
|
+
if (typeof tool !== "string" || tool.length > 100) throw new Error("Supply a valid tool ID.");
|
|
65
|
+
if (!Number.isSafeInteger(pageNumber) || pageNumber < 1 || pageNumber > 10000) throw new Error("--page must be an integer between 1 and 10000.");
|
|
66
|
+
if (status !== "available") throw new Error("Discovery only returns available APIs.");
|
|
67
|
+
return { q, tool, page: pageNumber };
|
|
68
|
+
}
|
|
69
|
+
export const platformId = (value: string) => value.replaceAll("_", "-");
|
|
70
|
+
export const searchTerms = (value: string) => value.toLowerCase().trim().split(/\s+/).filter(Boolean);
|
|
71
|
+
export const apiSearchText = (api: DataApi) => `${api.id} ${api.platform} ${api.category} ${typeof api.title === "string" ? api.title : Object.values(api.title).join(" ")} ${typeof api.description === "string" ? api.description : Object.values(api.description).join(" ")}`.toLowerCase();
|
|
72
|
+
const platformNames: Record<string, string> = { "tiktok-shop": "TikTok Shop", tiktok: "TikTok", twitter: "X / Twitter", linkedin: "LinkedIn", facebook: "Facebook", instagram: "Instagram", reddit: "Reddit", douyin: "Douyin", xiaohongshu: "Xiaohongshu", weibo: "Weibo", amazon: "Amazon", google: "Google Search", "google-ads": "Google Ads", similarweb: "Similarweb", semrush: "Semrush", web: "Web Content", "trade-data": "Trade Data", logistics: "Shipping" };
|
|
73
|
+
export function catalogTools(apis: DataApi[], query: string): Tool[] {
|
|
74
|
+
const groups = new Map<string, Omit<Tool, "apiCount"> & { apis: DataApi[] }>();
|
|
75
|
+
for (const api of apis) {
|
|
76
|
+
const id = platformId(api.platform), tool = groups.get(id) ?? { id, platform: api.platform, name: platformNames[id] ?? api.platform, apis: [] };
|
|
77
|
+
tool.apis.push(api); groups.set(id, tool);
|
|
78
|
+
}
|
|
79
|
+
const terms = searchTerms(query);
|
|
80
|
+
return [...groups.values()].filter(tool => terms.every(term => `${tool.id} ${tool.name} ${tool.apis.map(apiSearchText).join(" ")}`.toLowerCase().includes(term)))
|
|
81
|
+
.map(({ apis: children, ...tool }) => ({ ...tool, apiCount: children.length }));
|
|
82
|
+
}
|
package/src/output.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/** Terminal presentation, separate from the REST response envelopes. */
|
|
2
|
+
import { localized, isObject, validateRequest } from "./contracts";
|
|
3
|
+
import type { ApiDiscovery, ApiInspection, Balance, CommandName, CommandResults, DataApi, SoritRequest, ToolListing } from "./types";
|
|
4
|
+
const number = (value: number) => new Intl.NumberFormat("en-US", { maximumFractionDigits: 6 }).format(value);
|
|
5
|
+
const usd = (value: number) => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(value);
|
|
6
|
+
const text = (value: unknown) => String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
7
|
+
const json = (value: unknown) => JSON.stringify(value, null, 2);
|
|
8
|
+
const indent = (value: unknown) => String(value).split("\n").map(line => ` ${line}`).join("\n");
|
|
9
|
+
const heading = (label: string, body: unknown) => ` ${label}: ${text(body)}`;
|
|
10
|
+
const next = (command: string) => ` Next: ${command}`;
|
|
11
|
+
const creditPrice = (api: DataApi) => {
|
|
12
|
+
const credits = api.pricing?.credits;
|
|
13
|
+
if (typeof credits !== "number") return "Price unavailable";
|
|
14
|
+
return `${number(credits)} credits / ${api.pricing?.unit ?? "request"}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function balanceSummary(result: unknown): Balance {
|
|
18
|
+
if (!isObject(result) || typeof result.balance !== "number" || !Number.isFinite(result.balance) || typeof result.creditsPerUsd !== "number" || !Number.isFinite(result.creditsPerUsd) || result.creditsPerUsd <= 0) throw new Error("Sorit returned an invalid balance response.");
|
|
19
|
+
return { balance: result.balance, creditsPerUsd: result.creditsPerUsd, balanceUsd: Number((result.balance / result.creditsPerUsd).toFixed(6)), currency: "USD" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function apiRows(apis: DataApi[]) {
|
|
23
|
+
return apis.map(api => [
|
|
24
|
+
` ${text(localized(api.title) || api.id)}`,
|
|
25
|
+
` ${creditPrice(api)}`,
|
|
26
|
+
` ID: ${text(api.id)}`,
|
|
27
|
+
].join("\n")).join("\n\n");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function discover(result: ApiDiscovery) {
|
|
31
|
+
const apis = result.apis;
|
|
32
|
+
const total = result.total ?? apis.length;
|
|
33
|
+
if (!apis.length) return result.total > 0 ? `No APIs on page ${result.page}.\n\n ${number(result.total)} APIs match; choose a page from 1 to ${Math.ceil(result.total / result.pageSize)}.` : "No available APIs found.\n\n Try broader keywords or run sorit tools to browse platforms.";
|
|
34
|
+
return [
|
|
35
|
+
`✔ ${number(total)} available API${total === 1 ? "" : "s"} found`,
|
|
36
|
+
apiRows(apis),
|
|
37
|
+
` Page ${result.page ?? 1} / ${Math.ceil(total / (result.pageSize ?? apis.length))}`,
|
|
38
|
+
next(`sorit inspect ${text(apis[0].id)}`),
|
|
39
|
+
].join("\n\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function inspect(result: ApiInspection) {
|
|
43
|
+
const title = localized(result.title) || result.id, description = localized(result.description);
|
|
44
|
+
const lines = [
|
|
45
|
+
` ${text(title)}`,
|
|
46
|
+
...(description.trim() && description.trim() !== title.trim() ? [indent(text(description))] : []),
|
|
47
|
+
[
|
|
48
|
+
heading("ID", result.id),
|
|
49
|
+
heading("Status", result.status),
|
|
50
|
+
heading("Endpoint", `${result.invocation.method} ${result.invocation.path}`),
|
|
51
|
+
heading("Price", creditPrice(result)),
|
|
52
|
+
...(result.limits?.maxItems != null ? [heading("Maximum items", result.limits.maxItems)] : []),
|
|
53
|
+
].join("\n"),
|
|
54
|
+
...(result.inputSchema ? [" Input schema:\n" + indent(json(result.inputSchema))] : []),
|
|
55
|
+
...(result.outputSchema ? [" Output schema:\n" + indent(json(result.outputSchema))] : []),
|
|
56
|
+
next(`sorit run ${text(result.id)} --input @input.json --idempotency-key <unique-key> --wait`),
|
|
57
|
+
];
|
|
58
|
+
return lines.join("\n\n");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function requestResult(result: SoritRequest) {
|
|
62
|
+
const pending = ["queued", "running"].includes(result.status);
|
|
63
|
+
const failed = ["failed", "rejected"].includes(result.status);
|
|
64
|
+
const lines = [
|
|
65
|
+
`${failed ? "✖" : "✔"} Request ${text(result.status)}`,
|
|
66
|
+
[heading("Request", result.request_id), ...(result.api_id ? [heading("API", result.api_id)] : [])].join("\n"),
|
|
67
|
+
];
|
|
68
|
+
if (result.credits) {
|
|
69
|
+
const { charged, refunded } = result.credits;
|
|
70
|
+
if (typeof charged === "number" && typeof refunded === "number") {
|
|
71
|
+
lines.push(pending ? heading("Reserved", `${number(charged)} credits`) : heading("Spent", `${number(Number((charged - refunded).toFixed(2)))} credits`));
|
|
72
|
+
if (refunded > 0) lines.push(heading("Refunded", `${number(refunded)} credits`));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (result.error) lines.push(errorDetails(result.error));
|
|
76
|
+
if (result.data !== undefined && result.data !== null) lines.push(" Data:\n" + indent(json(result.data)));
|
|
77
|
+
if (pending) lines.push(next(`sorit get ${text(result.request_id)} --wait`));
|
|
78
|
+
return lines.join("\n\n");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const hints: Record<string, string> = {
|
|
82
|
+
UNAUTHORIZED: "Set SORIT_API_KEY to a valid key from your Sorit console.",
|
|
83
|
+
FORBIDDEN: "Use the account that owns this request.",
|
|
84
|
+
INSUFFICIENT_CREDITS: "Top up your Sorit account before creating another request.",
|
|
85
|
+
NOT_FOUND: "Run sorit discover to find an available API, or check the request ID.",
|
|
86
|
+
INVALID_INPUT: "Run sorit inspect <api-id> to check required parameters and limits.",
|
|
87
|
+
IDEMPOTENCY_CONFLICT: "Use the original idempotency key and identical input to resume the existing request.",
|
|
88
|
+
SERVICE_UNAVAILABLE: "Run sorit discover to find another available API, or retry later.",
|
|
89
|
+
RATE_LIMITED: "Wait before retrying. Keep the same input and idempotency key when resubmitting.",
|
|
90
|
+
SERVICE_TIMEOUT: "The data service timed out. Check this request before creating another one.",
|
|
91
|
+
};
|
|
92
|
+
function errorDetails(error: unknown): string {
|
|
93
|
+
const detail = isObject(error) ? error : {};
|
|
94
|
+
const code = text(typeof error === "string" ? error : detail.code).toUpperCase();
|
|
95
|
+
const message = text(typeof error === "string" ? code === "UNAUTHORIZED" ? "Authentication failed." : error : detail.message || detail.code || "Request failed.");
|
|
96
|
+
const fields = Array.isArray(detail.fields) ? detail.fields.filter(isObject).map(field => heading(Array.isArray(field.path) && field.path.length ? field.path.join(".") : text(field.field || "Input"), field.message || field.code || "Invalid value")) : [];
|
|
97
|
+
return [indent(message), ...(code && code !== message.toUpperCase() ? [heading("Code", code)] : []), ...fields, ...(hints[code] ? [indent(hints[code])] : [])].join("\n");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function formatError(error: unknown): string {
|
|
101
|
+
const detail = isObject(error) ? error : {}, result = isObject(detail.result) ? detail.result : undefined;
|
|
102
|
+
if (result?.request_id && result.status) {
|
|
103
|
+
try { return requestResult(validateRequest(result)); } catch { /* An invalid error envelope still gets a readable message. */ }
|
|
104
|
+
}
|
|
105
|
+
return `✖ ${errorDetails(result?.error ?? detail.message ?? "Request failed.").trimStart()}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tools(result: ToolListing): string {
|
|
109
|
+
if (!result.tools.length) return "No available tools found.\n\n Try broader keywords or run sorit tools without filters.";
|
|
110
|
+
return [
|
|
111
|
+
`✔ ${number(result.total)} available tool${result.total === 1 ? "" : "s"} found`,
|
|
112
|
+
result.tools.map(tool => ` ${text(tool.name)}\n ${number(tool.apiCount)} APIs\n ID: ${text(tool.id)}`).join("\n\n"),
|
|
113
|
+
next(`sorit discover --tool ${text(result.tools[0].id)}`),
|
|
114
|
+
].join("\n\n");
|
|
115
|
+
}
|
|
116
|
+
const renderers: { [K in CommandName]: (result: CommandResults[K]) => string } = {
|
|
117
|
+
tools, discover, inspect, run: requestResult, get: requestResult,
|
|
118
|
+
balance: result => `✔ Balance retrieved\n\n Balance: ${usd(result.balanceUsd)} USD\n Credits: ${number(result.balance)}`,
|
|
119
|
+
};
|
|
120
|
+
export function formatResult<K extends CommandName>(command: K, result: CommandResults[K]): string {
|
|
121
|
+
return renderers[command](result);
|
|
122
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export type JsonObject = Record<string, unknown>;
|
|
2
|
+
export type LocalizedText = string | { en?: string; zh?: string };
|
|
3
|
+
export type DataApi = {
|
|
4
|
+
id: string; platform: string; category: string; title: LocalizedText; description: LocalizedText; status: "available";
|
|
5
|
+
pricing: { currency: "USD"; amount: number; credits: number; unit: "request" | "record"; confirmed: true; failureRefund: true };
|
|
6
|
+
limits: { maxItems: number }; path: string;
|
|
7
|
+
};
|
|
8
|
+
export type ApiInspection = DataApi & {
|
|
9
|
+
inputSchema: JsonObject; outputSchema: JsonObject;
|
|
10
|
+
invocation: { method: "POST"; path: string; authentication: string; idempotencyHeader: string; resultPath: string };
|
|
11
|
+
requestProtocol: JsonObject;
|
|
12
|
+
};
|
|
13
|
+
export type ApiCatalog = {
|
|
14
|
+
schemaVersion: number; layer: "data_apis"; total: number; catalogTotal: number; apis: DataApi[];
|
|
15
|
+
categories: { id: string; count: number }[];
|
|
16
|
+
};
|
|
17
|
+
export type ApiDiscovery = ApiCatalog & { page: number; pageSize: number };
|
|
18
|
+
export type Tool = { id: string; platform: string; name: string; apiCount: number };
|
|
19
|
+
export type ToolListing = { schemaVersion: number; layer: "tools"; total: number; tools: Tool[] };
|
|
20
|
+
export type RequestStatus = "queued" | "running" | "succeeded" | "failed" | "rejected";
|
|
21
|
+
export type RequestError = { code: string; message: string; fields?: { path: (string | number)[]; message: string }[] };
|
|
22
|
+
export type SoritRequest = {
|
|
23
|
+
request_id: string; api_id: string | null; status: RequestStatus;
|
|
24
|
+
credits: { charged: number; refunded: number }; data: unknown; error: RequestError | null;
|
|
25
|
+
};
|
|
26
|
+
export type Balance = { balance: number; creditsPerUsd: number; balanceUsd: number; currency: "USD" };
|
|
27
|
+
export type CatalogFilters = { q?: string; tool?: string; page?: number | string; status?: string };
|
|
28
|
+
export type CommandResults = { tools: ToolListing; discover: ApiDiscovery; inspect: ApiInspection; run: SoritRequest; get: SoritRequest; balance: Balance };
|
|
29
|
+
export type CommandName = keyof CommandResults;
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"types": ["node"]
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*.ts"]
|
|
13
|
+
}
|
package/src/client.mjs
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
/** Internal HTTP transport for the Sorit CLI. */
|
|
2
|
-
export class SoritError extends Error {
|
|
3
|
-
constructor(message, { status = 0, result = null } = {}) {
|
|
4
|
-
super(message);
|
|
5
|
-
this.name = "SoritError";
|
|
6
|
-
this.status = status;
|
|
7
|
-
this.result = result;
|
|
8
|
-
}
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export class SoritClient {
|
|
12
|
-
/** @param {{baseUrl?: string, apiKey?: string, timeoutMs?: number}} [options] */
|
|
13
|
-
constructor({ baseUrl = "https://sorit.dev", apiKey, timeoutMs = 30_000 } = {}) {
|
|
14
|
-
const url = new URL(baseUrl);
|
|
15
|
-
if (!(["https:"].includes(url.protocol) || url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) throw new Error("Use HTTPS, or HTTP on localhost.");
|
|
16
|
-
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw new Error("baseUrl must be an origin.");
|
|
17
|
-
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("timeoutMs must be positive.");
|
|
18
|
-
this.baseUrl = url.origin;
|
|
19
|
-
this.apiKey = apiKey;
|
|
20
|
-
this.timeoutMs = timeoutMs;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
async request(path, { method = "GET", body, idempotencyKey, authenticated = false, timeoutMs = this.timeoutMs } = {}) {
|
|
24
|
-
if (!path.startsWith("/") || path.startsWith("//")) throw new Error("Use a Sorit API path.");
|
|
25
|
-
if (authenticated && !this.apiKey) throw new Error(`Set SORIT_API_KEY. Create an API key at ${this.baseUrl}/app/keys.`);
|
|
26
|
-
const headers = { Accept: "application/json" };
|
|
27
|
-
if (authenticated) headers.Authorization = `Bearer ${this.apiKey}`;
|
|
28
|
-
if (body !== undefined) headers["Content-Type"] = "application/json";
|
|
29
|
-
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
|
|
30
|
-
// Never forward a bearer key through a redirect, or automatically retry a paid POST.
|
|
31
|
-
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
32
|
-
method, headers, redirect: "error", signal: AbortSignal.timeout(timeoutMs),
|
|
33
|
-
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
34
|
-
});
|
|
35
|
-
let result;
|
|
36
|
-
try { result = await response.json(); }
|
|
37
|
-
catch { throw new SoritError("Sorit returned a non-JSON response.", { status: response.status }); }
|
|
38
|
-
if (!response.ok) throw new SoritError((typeof result.error === "string" ? result.error : result.error?.message) ?? "Sorit request failed.", { status: response.status, result });
|
|
39
|
-
return result;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
tools({ q = "", page = 1 } = {}) {
|
|
43
|
-
return this.request(`/api/tools?${new URLSearchParams({ q, status: "available", page: String(page) })}`);
|
|
44
|
-
}
|
|
45
|
-
endpoints({ tool = "", q = "", page = 1 } = {}) {
|
|
46
|
-
return this.request(`/api/endpoints?${new URLSearchParams({ tool, q, status: "available", page: String(page) })}`);
|
|
47
|
-
}
|
|
48
|
-
describe(apiId) { return this.request(`/api/tools/${encodeURIComponent(apiId)}`); }
|
|
49
|
-
create(apiId, input, { idempotencyKey } = {}) {
|
|
50
|
-
if (!idempotencyKey || !/^[A-Za-z0-9._:-]{1,128}$/.test(idempotencyKey)) throw new Error("Supply a unique idempotencyKey (1–128 letters, numbers, dots, underscores, colons or hyphens); reuse it for retries.");
|
|
51
|
-
return this.request(apiId.includes(".") ? `/api/v1/run/${encodeURIComponent(apiId)}` : "/api/jobs", { method: "POST", authenticated: true, body: apiId.includes(".") ? input : { kind: apiId, input }, idempotencyKey });
|
|
52
|
-
}
|
|
53
|
-
get(requestId, { timeoutMs } = {}) {
|
|
54
|
-
return this.request(`/api/jobs/${encodeURIComponent(requestId)}`, { authenticated: true, timeoutMs });
|
|
55
|
-
}
|
|
56
|
-
async wait(requestId, { intervalMs = 1500, timeoutMs = 120_000 } = {}) {
|
|
57
|
-
if (!Number.isFinite(intervalMs) || intervalMs < 100 || !Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error("Use intervalMs >= 100 and timeoutMs > 0.");
|
|
58
|
-
const deadline = Date.now() + timeoutMs;
|
|
59
|
-
while (Date.now() < deadline) {
|
|
60
|
-
const result = await this.get(requestId, { timeoutMs: Math.max(1, Math.min(this.timeoutMs, deadline - Date.now())) });
|
|
61
|
-
if (["succeeded", "failed", "rejected"].includes(result.status)) return result;
|
|
62
|
-
if (!["queued", "running"].includes(result.status)) throw new SoritError("Unexpected request status.", { result });
|
|
63
|
-
const remaining = deadline - Date.now();
|
|
64
|
-
if (remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(intervalMs, remaining)));
|
|
65
|
-
}
|
|
66
|
-
throw new SoritError(`Polling timed out for request_id ${requestId}. Resume with sorit get <request-id> --wait; the request may still be running.`);
|
|
67
|
-
}
|
|
68
|
-
}
|
package/src/output.mjs
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/** Terminal presentation, separate from the REST response envelopes. */
|
|
2
|
-
const number = value => new Intl.NumberFormat("en-US", { maximumFractionDigits: 6 }).format(value);
|
|
3
|
-
const usd = value => new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: 2, maximumFractionDigits: 6 }).format(value);
|
|
4
|
-
const text = value => String(value ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").trim();
|
|
5
|
-
const json = value => JSON.stringify(value, null, 2);
|
|
6
|
-
const indent = value => String(value).split("\n").map(line => ` ${line}`).join("\n");
|
|
7
|
-
const heading = (label, body) => ` ${label}: ${text(body)}`;
|
|
8
|
-
const next = command => ` Next: ${command}`;
|
|
9
|
-
const creditPrice = api => {
|
|
10
|
-
const credits = api.pricing?.credits ?? api.policy?.credits;
|
|
11
|
-
if (typeof credits !== "number") return "Price unavailable";
|
|
12
|
-
return `${number(credits)} credits ${api.billing || api.pricing?.billing ? "maximum reservation" : `/ ${api.pricing?.unit ?? "request"}`}`;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
export function balanceSummary(result) {
|
|
16
|
-
if (typeof result?.balance !== "number" || !Number.isFinite(result.balance) || typeof result.creditsPerUsd !== "number" || !Number.isFinite(result.creditsPerUsd) || result.creditsPerUsd <= 0) throw new Error("Sorit returned an invalid balance response.");
|
|
17
|
-
return { balance: result.balance, creditsPerUsd: result.creditsPerUsd, balanceUsd: Number((result.balance / result.creditsPerUsd).toFixed(6)), currency: "USD" };
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function apiRows(apis) {
|
|
21
|
-
return apis.map(api => [
|
|
22
|
-
` ${text(api.title?.en || api.name || api.id)}`,
|
|
23
|
-
` ${api.providerName || api.provider ? text(api.providerName || api.provider) + " · " : ""}${creditPrice(api)}`,
|
|
24
|
-
` ID: ${text(api.id)}`,
|
|
25
|
-
].join("\n")).join("\n\n");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function discover(result) {
|
|
29
|
-
const apis = result.apis ?? result.tools ?? [];
|
|
30
|
-
const total = result.total ?? apis.length;
|
|
31
|
-
if (!apis.length) return "No available APIs found.\n\n Try broader keywords or run sorit tools to browse platforms.";
|
|
32
|
-
return [
|
|
33
|
-
`✔ ${number(total)} available API${total === 1 ? "" : "s"} found`,
|
|
34
|
-
apiRows(apis),
|
|
35
|
-
` Page ${result.page ?? 1} / ${Math.ceil(total / (result.pageSize ?? apis.length))}`,
|
|
36
|
-
next(`sorit inspect ${text(apis[0].id)}`),
|
|
37
|
-
].join("\n\n");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function inspect(result) {
|
|
41
|
-
if (result.layer === "tool") return ` ${text(result.id)}\n\n${discover(result.endpoints)}`;
|
|
42
|
-
const rates = result.pricing?.billing;
|
|
43
|
-
const unit = { limit: "result", keywords: "keyword", asins: "ASIN" }[rates?.measure];
|
|
44
|
-
const lines = [
|
|
45
|
-
` ${text(result.title?.en || result.name || result.id)}`,
|
|
46
|
-
...(typeof result.description === "string" && result.description.trim() !== result.name?.trim() ? [indent(text(result.description))] : []),
|
|
47
|
-
[
|
|
48
|
-
heading("ID", result.id),
|
|
49
|
-
heading("Status", result.status),
|
|
50
|
-
...(result.providerName || result.provider ? [heading("Provider", result.providerName || result.provider)] : []),
|
|
51
|
-
...(result.method && result.endpoint ? [heading("Endpoint", `${result.method} ${result.endpoint}`)] : []),
|
|
52
|
-
heading("Price", creditPrice(result)),
|
|
53
|
-
...(rates && unit ? [heading("Base fee", `${number(rates.base)} credits / request`), heading("Unit fee", `${number(rates.perUnit)} credits / ${unit}`)] : []),
|
|
54
|
-
...(rates?.measure === "budget" ? [heading("Usage", "Settled from provider cost; unused reservation is refunded.")] : []),
|
|
55
|
-
...(result.limits?.maxItems != null ? [heading("Maximum items", result.limits.maxItems)] : []),
|
|
56
|
-
].join("\n"),
|
|
57
|
-
...(result.inputSchema ? [" Input schema:\n" + indent(json(result.inputSchema))] : []),
|
|
58
|
-
next(`sorit run ${text(result.id)} --input @input.json --idempotency-key <unique-key> --wait`),
|
|
59
|
-
];
|
|
60
|
-
return lines.join("\n\n");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function requestResult(result) {
|
|
64
|
-
const pending = ["queued", "running"].includes(result.status);
|
|
65
|
-
const failed = ["failed", "rejected"].includes(result.status);
|
|
66
|
-
const lines = [
|
|
67
|
-
`${failed ? "✖" : "✔"} Request ${text(result.status)}`,
|
|
68
|
-
[heading("Request", result.request_id), ...(result.api_id ? [heading("API", result.api_id)] : [])].join("\n"),
|
|
69
|
-
];
|
|
70
|
-
if (result.credits) {
|
|
71
|
-
const { charged, refunded } = result.credits;
|
|
72
|
-
if (typeof charged === "number" && typeof refunded === "number") {
|
|
73
|
-
lines.push(pending ? heading("Reserved", `${number(charged)} credits`) : heading("Spent", `${number(Number((charged - refunded).toFixed(2)))} credits`));
|
|
74
|
-
if (refunded > 0) lines.push(heading("Refunded", `${number(refunded)} credits`));
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (result.error) lines.push(errorDetails(result.error));
|
|
78
|
-
if (result.data !== undefined && result.data !== null) lines.push(" Data:\n" + indent(json(result.data)));
|
|
79
|
-
if (pending) lines.push(next(`sorit get ${text(result.request_id)} --wait`));
|
|
80
|
-
return lines.join("\n\n");
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const hints = {
|
|
84
|
-
UNAUTHORIZED: "Set SORIT_API_KEY to a valid key from your Sorit console.",
|
|
85
|
-
FORBIDDEN: "Use the account that owns this request.",
|
|
86
|
-
INSUFFICIENT_CREDITS: "Top up your Sorit account before creating another request.",
|
|
87
|
-
NOT_FOUND: "Run sorit discover to find an available API, or check the request ID.",
|
|
88
|
-
INVALID_INPUT: "Run sorit inspect <api-id> to check required parameters and limits.",
|
|
89
|
-
IDEMPOTENCY_CONFLICT: "Use the original idempotency key and identical input to resume the existing request.",
|
|
90
|
-
SERVICE_UNAVAILABLE: "Run sorit discover to find another available API, or retry later.",
|
|
91
|
-
};
|
|
92
|
-
function errorDetails(error) {
|
|
93
|
-
const code = text(typeof error === "string" ? error : error?.code).toUpperCase();
|
|
94
|
-
const message = text(typeof error === "string" ? code === "UNAUTHORIZED" ? "Authentication failed." : error : error?.message || error?.code || "Request failed.");
|
|
95
|
-
const fields = Array.isArray(error?.fields) ? error.fields.map(field => heading(Array.isArray(field.path) && field.path.length ? field.path.join(".") : text(field.field || "Input"), field.message || field.code || "Invalid value")) : [];
|
|
96
|
-
return [indent(message), ...(code && code !== message.toUpperCase() ? [heading("Code", code)] : []), ...fields, ...(hints[code] ? [indent(hints[code])] : [])].join("\n");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function formatError(error) {
|
|
100
|
-
const result = error?.result;
|
|
101
|
-
if (result?.request_id && result.status) return requestResult(result);
|
|
102
|
-
return `✖ ${errorDetails(result?.error ?? error?.message ?? "Request failed.").trimStart()}`;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function formatResult(command, result) {
|
|
106
|
-
if (command === "balance") return `✔ Balance retrieved\n\n Balance: ${usd(result.balanceUsd)} USD\n Credits: ${number(result.balance)}`;
|
|
107
|
-
if (command === "discover") return discover(result);
|
|
108
|
-
if (command === "inspect") return inspect(result);
|
|
109
|
-
if (command === "tools") {
|
|
110
|
-
const tools = result.tools ?? [];
|
|
111
|
-
if (result.apis) return discover(result);
|
|
112
|
-
if (!tools.length) return "No available tools found.\n\n Try broader keywords or run sorit tools without filters.";
|
|
113
|
-
return [
|
|
114
|
-
`✔ ${number(result.total ?? tools.length)} available tools found`,
|
|
115
|
-
tools.map(tool => ` ${text(tool.name || tool.id)}\n ${number(tool.endpointCount)} APIs · ${(tool.providers ?? []).map(provider => text(provider.name)).join(", ")}\n ID: ${text(tool.id)}`).join("\n\n"),
|
|
116
|
-
next(`sorit discover --tool ${text(tools[0].id)}`),
|
|
117
|
-
].join("\n\n");
|
|
118
|
-
}
|
|
119
|
-
return requestResult(result);
|
|
120
|
-
}
|