@pyai/sdk 0.3.1 → 0.5.0

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.
@@ -0,0 +1,286 @@
1
+ /** HTTP transport for the CLI. SDK request behavior remains independent. */
2
+ export class CliError extends Error {
3
+ code;
4
+ exitCode;
5
+ details;
6
+ constructor(code, message, exitCode = 1, details) {
7
+ super(message);
8
+ this.name = "CliError";
9
+ this.code = code;
10
+ this.exitCode = exitCode;
11
+ this.details = details;
12
+ }
13
+ }
14
+ function record(value) {
15
+ return value !== null && typeof value === "object" && !Array.isArray(value)
16
+ ? value : {};
17
+ }
18
+ function string(value) {
19
+ return typeof value === "string" && value.length > 0 ? value : undefined;
20
+ }
21
+ function timeout(value) {
22
+ if (!Number.isFinite(value) || value <= 0 || value > 2_147_483_647) {
23
+ throw new CliError("invalid_timeout", "Request timeout must be a positive number of milliseconds (at most 2147483647).", 2);
24
+ }
25
+ return value;
26
+ }
27
+ function retryDelay(header, attempt) {
28
+ if (header !== null) {
29
+ const seconds = Number(header);
30
+ if (header.trim() !== "" && Number.isFinite(seconds) && seconds >= 0)
31
+ return seconds * 1000;
32
+ const date = Date.parse(header);
33
+ if (Number.isFinite(date))
34
+ return Math.max(0, date - Date.now());
35
+ }
36
+ return Math.min(200 * 2 ** attempt, 2000);
37
+ }
38
+ function pause(ms, signal) {
39
+ return new Promise((resolve, reject) => {
40
+ if (signal.aborted) {
41
+ reject(signal.reason);
42
+ return;
43
+ }
44
+ const finish = () => { signal.removeEventListener("abort", abort); resolve(); };
45
+ const timer = setTimeout(finish, ms);
46
+ const abort = () => { clearTimeout(timer); signal.removeEventListener("abort", abort); reject(signal.reason); };
47
+ signal.addEventListener("abort", abort, { once: true });
48
+ });
49
+ }
50
+ export class CliHttp {
51
+ baseURL;
52
+ apiKey;
53
+ timeoutMs;
54
+ maxRetries;
55
+ constructor(options) {
56
+ try {
57
+ this.baseURL = new URL(options.baseURL);
58
+ }
59
+ catch {
60
+ throw new CliError("invalid_base_url", "API base URL must be an absolute HTTP or HTTPS URL.", 2);
61
+ }
62
+ if (!["http:", "https:"].includes(this.baseURL.protocol) || this.baseURL.username || this.baseURL.password
63
+ || this.baseURL.search || this.baseURL.hash) {
64
+ throw new CliError("invalid_base_url", "API base URL must use HTTP or HTTPS without credentials, a query, or a fragment.", 2);
65
+ }
66
+ this.apiKey = options.apiKey;
67
+ this.timeoutMs = timeout(options.timeoutMs ?? 30_000);
68
+ this.maxRetries = options.maxRetries ?? 2;
69
+ if (!Number.isSafeInteger(this.maxRetries) || this.maxRetries < 0) {
70
+ throw new CliError("invalid_retries", "Maximum retries must be a non-negative integer.", 2);
71
+ }
72
+ }
73
+ request(method, path, options = {}) {
74
+ return this.execute(method, path, options, false);
75
+ }
76
+ json(method, path, options = {}) {
77
+ return this.execute(method, path, options, true);
78
+ }
79
+ /** Validate previews through the same URL rules used by live requests. */
80
+ validatePath(path) {
81
+ return this.url(path).toString();
82
+ }
83
+ redact(value) {
84
+ let result = value;
85
+ if (this.apiKey) {
86
+ result = result.split(this.apiKey).join("[REDACTED]");
87
+ result = result.split(encodeURIComponent(this.apiKey)).join("[REDACTED]");
88
+ }
89
+ return result.replace(/\bBearer\s+[^\s"',;<>]+/gi, "Bearer [REDACTED]")
90
+ .replace(/\bpyai_(?:test|live)_[A-Za-z0-9._~-]+/g, "[REDACTED]");
91
+ }
92
+ url(path) {
93
+ if (!/^\/(?![\/\\])/.test(path) || /[\\\u0000-\u0020\u007f#]/.test(path)) {
94
+ throw new CliError("invalid_path", "API path must begin with a single / and contain no backslashes, whitespace, or fragment.", 2);
95
+ }
96
+ if (path.split("?", 1)[0].split("/").some((segment) => [".", ".."].includes(segment.replace(/%2e/gi, ".")))) {
97
+ throw new CliError("invalid_path", "API path must not contain . or .. traversal segments.", 2);
98
+ }
99
+ const url = new URL(this.baseURL.toString().replace(/\/+$/, "") + path);
100
+ if (url.origin !== this.baseURL.origin || url.username || url.password) {
101
+ throw new CliError("invalid_path", "API path must remain on the configured API origin.", 2);
102
+ }
103
+ for (const name of url.searchParams.keys()) {
104
+ if (name.toLowerCase() === "api_key") {
105
+ throw new CliError("unsafe_query", "Pass API keys through authentication, never through a URL query.", 2);
106
+ }
107
+ }
108
+ return url;
109
+ }
110
+ transportError(error, signal) {
111
+ if (signal.aborted && signal.reason instanceof CliError)
112
+ return signal.reason;
113
+ if (error instanceof CliError)
114
+ return error;
115
+ const message = error instanceof Error ? error.message : "Connection failed.";
116
+ return new CliError("network_error", this.redact(`Network request failed: ${message}`), 4);
117
+ }
118
+ async apiError(response) {
119
+ const text = await response.text();
120
+ let payload = {};
121
+ try {
122
+ payload = record(JSON.parse(text));
123
+ }
124
+ catch { /* Non-JSON gateways still expose their HTTP status. */ }
125
+ const nested = record(payload.error);
126
+ const problemType = string(payload.type);
127
+ const problemCode = problemType?.match(/^https?:\/\/[^/]+\/problems\/([^/?#]+)$/)?.[1];
128
+ const fallbackCode = response.status === 401 ? "unauthorized" : response.status === 403 ? "forbidden" : `http_${response.status}`;
129
+ const code = this.redact(string(nested.code) ?? string(payload.code) ?? problemCode ?? fallbackCode);
130
+ const message = this.redact(string(nested.message) ?? string(payload.detail) ?? string(payload.message)
131
+ ?? string(payload.title) ?? `API request failed (HTTP ${response.status}).`);
132
+ const details = { status: response.status };
133
+ const requestId = response.headers.get("x-request-id") ?? response.headers.get("request-id")
134
+ ?? string(payload.request_id) ?? string(nested.request_id);
135
+ const retryAfter = response.headers.get("retry-after") ?? string(payload.retry_after);
136
+ if (requestId)
137
+ details.request_id = this.redact(requestId);
138
+ if (retryAfter !== undefined && retryAfter !== null)
139
+ details.retry_after = this.redact(retryAfter);
140
+ if (string(nested.type))
141
+ details.type = this.redact(nested.type);
142
+ if (typeof nested.param === "string")
143
+ details.param = this.redact(nested.param);
144
+ return new CliError(code, message, response.status === 401 || response.status === 403 ? 3 : 1, details);
145
+ }
146
+ /** Preserve the request deadline after headers, including paused or stalled downloads. */
147
+ stream(response, abort, cleanup) {
148
+ if (!response.body) {
149
+ cleanup();
150
+ return response;
151
+ }
152
+ const reader = response.body.getReader();
153
+ let finished = false;
154
+ let stop;
155
+ const finish = () => {
156
+ finished = true;
157
+ abort.signal.removeEventListener("abort", stop);
158
+ cleanup();
159
+ };
160
+ const body = new ReadableStream({
161
+ start: (controller) => {
162
+ stop = () => {
163
+ if (finished)
164
+ return;
165
+ finish();
166
+ controller.error(this.transportError(abort.signal.reason, abort.signal));
167
+ void reader.cancel().catch(() => { });
168
+ };
169
+ abort.signal.addEventListener("abort", stop, { once: true });
170
+ if (abort.signal.aborted)
171
+ stop();
172
+ },
173
+ pull: async (controller) => {
174
+ try {
175
+ const result = await reader.read();
176
+ if (finished)
177
+ return;
178
+ if (result.done) {
179
+ finish();
180
+ controller.close();
181
+ }
182
+ else
183
+ controller.enqueue(result.value);
184
+ }
185
+ catch (error) {
186
+ if (finished)
187
+ return;
188
+ finish();
189
+ controller.error(this.transportError(error, abort.signal));
190
+ }
191
+ },
192
+ cancel: async (reason) => {
193
+ finish();
194
+ abort.abort();
195
+ await reader.cancel(reason).catch(() => { });
196
+ },
197
+ });
198
+ const result = new Response(body, { status: response.status, statusText: response.statusText, headers: response.headers });
199
+ Object.defineProperties(result, {
200
+ url: { value: response.url }, redirected: { value: response.redirected }, type: { value: response.type },
201
+ });
202
+ return result;
203
+ }
204
+ async execute(method, path, options, readJson) {
205
+ const url = this.url(path);
206
+ method = method.toUpperCase();
207
+ if (!/^[A-Z]+$/.test(method) || ["CONNECT", "TRACE", "TRACK"].includes(method)) {
208
+ throw new CliError("invalid_method", "Unsupported HTTP method.", 2);
209
+ }
210
+ if (options.auth !== false && !this.apiKey) {
211
+ throw new CliError("missing_api_key", "No API key. Set PYAI_API_KEY, configure a profile, or pass --api-key.", 3);
212
+ }
213
+ if (options.json !== undefined && options.body !== undefined) {
214
+ throw new CliError("invalid_body", "Provide either a JSON body or a raw body, not both.", 2);
215
+ }
216
+ let headers;
217
+ let body;
218
+ try {
219
+ headers = new Headers(options.headers);
220
+ headers.delete("authorization");
221
+ headers.delete("x-api-key");
222
+ if (options.auth !== false)
223
+ headers.set("authorization", `Bearer ${this.apiKey}`);
224
+ body = options.json === undefined ? options.body : JSON.stringify(options.json);
225
+ if (options.json !== undefined)
226
+ headers.set("content-type", "application/json");
227
+ }
228
+ catch {
229
+ throw new CliError("invalid_request", "Request headers or JSON body are invalid.", 2);
230
+ }
231
+ if ((method === "GET" || method === "HEAD") && body !== undefined) {
232
+ throw new CliError("invalid_body", `${method} requests cannot have a body.`, 2);
233
+ }
234
+ const duration = timeout(options.timeoutMs ?? this.timeoutMs);
235
+ const deadline = Date.now() + duration;
236
+ const abort = new AbortController();
237
+ const timer = setTimeout(() => abort.abort(new CliError("timeout", `Request timed out after ${duration} ms.`, 4)), duration);
238
+ const cleanup = () => clearTimeout(timer);
239
+ const retries = method === "GET" || method === "HEAD" ? this.maxRetries : 0;
240
+ let streaming = false;
241
+ try {
242
+ for (let attempt = 0;; attempt++) {
243
+ let delay = retryDelay(null, attempt);
244
+ try {
245
+ const response = await fetch(url, { method, headers, body, signal: abort.signal, redirect: "manual" });
246
+ if (response.status >= 300 && response.status < 400) {
247
+ await response.body?.cancel();
248
+ throw new CliError("redirect_refused", "API redirects are refused. Configure the final API base URL directly.", 1, { status: response.status });
249
+ }
250
+ if ((response.status === 429 || response.status >= 500) && attempt < retries) {
251
+ delay = retryDelay(response.headers.get("retry-after"), attempt);
252
+ await response.body?.cancel();
253
+ }
254
+ else {
255
+ if (!response.ok)
256
+ throw await this.apiError(response);
257
+ if (readJson) {
258
+ const text = await response.text();
259
+ if (!text.trim())
260
+ return null;
261
+ try {
262
+ return JSON.parse(text);
263
+ }
264
+ catch {
265
+ throw new CliError("invalid_response", "API returned an invalid JSON response.", 1, { status: response.status });
266
+ }
267
+ }
268
+ const result = this.stream(response, abort, cleanup);
269
+ streaming = response.body !== null;
270
+ return result;
271
+ }
272
+ }
273
+ catch (error) {
274
+ const failure = this.transportError(error, abort.signal);
275
+ if (abort.signal.aborted || failure.code !== "network_error" || failure.exitCode !== 4 || attempt >= retries)
276
+ throw failure;
277
+ }
278
+ await pause(Math.min(delay, Math.max(0, deadline - Date.now())), abort.signal);
279
+ }
280
+ }
281
+ finally {
282
+ if (!streaming)
283
+ cleanup();
284
+ }
285
+ }
286
+ }
@@ -0,0 +1,27 @@
1
+ export type CliTemplate = "agent" | "typescript" | "python";
2
+ export interface CliTemplateManifest {
3
+ id: CliTemplate;
4
+ description: string;
5
+ files: readonly string[];
6
+ }
7
+ export interface ScaffoldOptions {
8
+ directory: string;
9
+ template?: CliTemplate;
10
+ dryRun?: boolean;
11
+ cwd?: string;
12
+ }
13
+ export interface ScaffoldResult {
14
+ directory: string;
15
+ template: CliTemplate;
16
+ files: string[];
17
+ created: boolean;
18
+ next_steps: string[];
19
+ }
20
+ export declare class CliInitError extends Error {
21
+ readonly code: string;
22
+ constructor(code: string, message: string);
23
+ }
24
+ /** Offline discovery; file contents are static and never include local credentials. */
25
+ export declare const CLI_TEMPLATES: readonly CliTemplateManifest[];
26
+ /** Create a new project only. Never authenticates, installs packages, or runs a shell. */
27
+ export declare function scaffoldProject(options: ScaffoldOptions): Promise<ScaffoldResult>;