@sorit/cli 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,49 @@
1
- # Temporary Holding Version
1
+ # Sorit CLI
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Discover and call commerce, social and search APIs through your Sorit account.
4
+
5
+ ```sh
6
+ bun install --global @sorit/cli
7
+ export SORIT_API_KEY='<YOUR_API_KEY>'
8
+ sorit discover --tool tiktok
9
+ sorit inspect '<API_ID>'
10
+ export SORIT_REQUEST_KEY="$(bun -e 'console.log(crypto.randomUUID())')"
11
+ sorit run '<API_ID>' --input @input.json --idempotency-key "$SORIT_REQUEST_KEY" --wait
12
+ sorit get '<REQUEST_ID>' --wait
13
+ ```
14
+
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
+
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
+
19
+ ## Output
20
+
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.
22
+
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.
24
+
25
+ ```sh
26
+ sorit balance
27
+ sorit balance --json
28
+ sorit discover --tool tiktok --json
29
+ sorit get '<REQUEST_ID>' --wait --json
30
+ ```
31
+
32
+ ## Commands and help
33
+
34
+ | Command | Purpose |
35
+ | --- | --- |
36
+ | `sorit tools` | List data tools and platforms. |
37
+ | `sorit discover` | Search APIs by tool or keywords. |
38
+ | `sorit inspect <api-id>` | Read the input schema, price and availability. |
39
+ | `sorit run <api-id>` | Create a paid request; optionally wait for results. |
40
+ | `sorit get <request-id>` | Fetch status and results, or resume waiting. |
41
+ | `sorit balance` | Read the account credit balance. |
42
+
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.
44
+
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.
46
+
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.
48
+
49
+ Agent instructions: https://sorit.dev/SKILL.md. REST reference: https://sorit.dev/en/docs/api/jobs.
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "@sorit/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Discover and call Sorit data APIs from your terminal or AI agent",
5
+ "type": "module",
6
+ "bin": {
7
+ "sorit": "src/cli.ts"
8
+ },
9
+ "files": [
10
+ "src/*.ts",
11
+ "tsconfig.json",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "bun": ">=1.2.0"
16
+ },
17
+ "homepage": "https://sorit.dev",
18
+ "publishConfig": {
19
+ "access": "public",
20
+ "registry": "https://registry.npmjs.org",
21
+ "@sorit:registry": "https://registry.npmjs.org"
22
+ },
23
+ "scripts": {
24
+ "typecheck": "tsc --noEmit"
25
+ }
26
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env bun
2
+ import { readFile } from "node:fs/promises";
3
+ import { parseArgs } from "node:util";
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";
8
+
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;
12
+ const flags = {
13
+ search: { syntax: "--search <text>", description: "Filter by keywords." },
14
+ status: { syntax: "--status <status>", description: "Only available is supported; unavailable APIs are excluded." },
15
+ tool: { syntax: "--tool <tool-id>", description: "Filter by a tool ID, such as tiktok or twitter." },
16
+ page: { syntax: "--page <number>", description: "Page number, 1–10000. Default: 1." },
17
+ input: { syntax: "--input <json|@file>", description: "Input JSON object or @file.json. Required." },
18
+ "idempotency-key": { syntax: "--idempotency-key <key>", description: "Unique request key; reuse it with the same input on retries. Required." },
19
+ wait: { syntax: "--wait", description: "Poll until the request finishes." },
20
+ timeout: { syntax: "--timeout <milliseconds>", description: "Polling time limit with --wait. Default: 120000." },
21
+ };
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> = {
25
+ tools: {
26
+ description: "List data tools and platforms.",
27
+ usage: "tools [options]", options: ["search", "status"],
28
+ notes: "Only tools with callable APIs are returned. No API key required.",
29
+ examples: ["sorit tools", 'sorit tools --search "social"'],
30
+ },
31
+ discover: {
32
+ description: "Search for data APIs by tool or keywords.",
33
+ usage: "discover [options]", options: ["tool", "search", "status", "page"],
34
+ notes: "Only callable APIs are returned. Disabled, unconfigured and unmapped APIs are excluded.\nNo API key required.",
35
+ examples: ["sorit discover --tool tiktok", 'sorit discover --search "search volume" --page 1'],
36
+ },
37
+ inspect: {
38
+ description: "Show an API's input schema, price and availability.",
39
+ usage: "inspect <api-id>", options: [],
40
+ argument: ["<api-id>", "Registered API ID returned by discover."],
41
+ notes: "Inspect an API before calling it. No API key required.",
42
+ examples: ["sorit inspect <api-id>"],
43
+ },
44
+ run: {
45
+ description: "Create a paid API request and optionally wait for results.",
46
+ usage: "run <api-id> --input <json|@file> --idempotency-key <key> [options]",
47
+ options: ["input", "idempotency-key", "wait", "timeout"],
48
+ argument: ["<api-id>", "Registered Sorit data API ID returned by discover."],
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.",
50
+ examples: [
51
+ "sorit run <api-id> --input @input.json --idempotency-key research-001 --wait",
52
+ `sorit run <api-id> --input '{"limit":10}' --idempotency-key research-002`,
53
+ ],
54
+ },
55
+ get: {
56
+ description: "Fetch a request's status and results, or resume waiting.",
57
+ usage: "get <request-id> [options]", options: ["wait", "timeout"],
58
+ argument: ["<request-id>", "request_id returned by run."],
59
+ notes: "Requires SORIT_API_KEY. Polling does not create another paid request.\nA wait timeout does not cancel the request.",
60
+ examples: ["sorit get <request-id>", "sorit get <request-id> --wait --timeout 120000"],
61
+ },
62
+ balance: {
63
+ description: "Show your account's credit balance.",
64
+ usage: "balance [options]", options: [],
65
+ notes: "Requires SORIT_API_KEY. Shows USD equivalent and credits; no checkout configuration.", examples: ["sorit balance", "sorit balance --json"],
66
+ },
67
+ };
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][] = [
71
+ ["-h, --help", "Show help for this command."],
72
+ ["-V, --version", "Show the version number (-v also supported)."],
73
+ ["--json", "Output structured JSON for scripts and agents."],
74
+ ];
75
+ function help(command?: CommandName) {
76
+ const spec = command ? commands[command] : undefined;
77
+ return [
78
+ section("Usage", ` sorit ${spec?.usage ?? "<command> [options]"}`),
79
+ section("Version", ` ${version}`),
80
+ section("Description", ` ${spec?.description ?? "Sorit CLI — discover, inspect and run data APIs."}`),
81
+ ...(spec?.argument ? [section("Arguments", rows([spec.argument]))] : []),
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."]]))] : []),
84
+ section("Environment", rows([
85
+ ["SORIT_API_KEY", "API key for run, get and balance."],
86
+ ["SORIT_URL", "Optional origin override for local deployments."],
87
+ ])),
88
+ "API server: https://sorit.dev (default; no configuration required).",
89
+ ...(spec?.notes ? [section("Notes", spec.notes.split("\n").map(line => ` ${line}`).join("\n"))] : []),
90
+ section("Examples", (spec?.examples ?? ["sorit discover --tool tiktok", "sorit inspect <api-id>", "sorit run --help"]).map(line => ` ${line}`).join("\n")),
91
+ "Docs: https://sorit.dev/en/docs/integrations",
92
+ ].join("\n\n");
93
+ }
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
+
103
+ let jsonOutput = process.argv.slice(2).includes("--json");
104
+ try {
105
+ const { values, positionals } = parseArgs({ args: process.argv.slice(2).map(arg => arg === "-v" ? "-V" : arg), allowPositionals: true, options: {
106
+ version: { type: "boolean", short: "V" }, help: { type: "boolean", short: "h" }, wait: { type: "boolean" },
107
+ input: { type: "string" }, "idempotency-key": { type: "string" },
108
+ tool: { type: "string" }, search: { type: "string" }, page: { type: "string" },
109
+ status: { type: "string" }, timeout: { type: "string" },
110
+ json: { type: "boolean" },
111
+ } });
112
+ jsonOutput = Boolean(values.json);
113
+ const explicitHelp = positionals[0] === "help";
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;
118
+ for (const name of Object.keys(values)) {
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.`);
120
+ }
121
+ if (explicitHelp && positionals.length > 2) throw new Error("Usage: sorit help [command].");
122
+ if (values.version) console.log(version);
123
+ else if (values.help || explicitHelp || !command) console.log(help(command));
124
+ else {
125
+ const spec = commands[command];
126
+ const target = id ?? "";
127
+ if (positionals.length !== (spec.argument ? 2 : 1) || spec.argument && !id?.trim()) throw new Error(`Usage: sorit ${spec.usage}. See sorit ${command} --help.`);
128
+ if (values.timeout !== undefined && !values.wait) throw new Error("--timeout requires --wait.");
129
+ const timeoutMs = Number(values.timeout ?? 120000);
130
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > 4294967295) throw new Error("--timeout must be an integer between 1 and 4294967295 milliseconds.");
131
+ if (values.page !== undefined && (!/^\d+$/.test(values.page) || Number(values.page) < 1 || Number(values.page) > 10000)) throw new Error("--page must be an integer between 1 and 10000.");
132
+ if (values.status !== undefined && values.status !== "available") throw new Error("Search only returns available APIs. --status must be available.");
133
+ const client = new SoritClient({ baseUrl: process.env.SORIT_URL?.trim() || undefined, apiKey: process.env.SORIT_API_KEY ?? process.env.SELLERTOOL_API_KEY });
134
+ const filters = { q: values.search, page: values.page, status: values.status };
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;
157
+ }
158
+ }
159
+ }
160
+ } catch (error) {
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));
163
+ process.exitCode = 1;
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
+ }
@@ -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
+ }