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