@pyai/sdk 0.4.0 → 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.
- package/AGENT_GUIDE.md +296 -0
- package/CLI.md +609 -0
- package/CLI.schema.json +1085 -0
- package/README.md +19 -22
- package/dist/cli-config.d.ts +47 -0
- package/dist/cli-config.js +265 -0
- package/dist/cli-dx.d.ts +11 -0
- package/dist/cli-dx.js +32 -0
- package/dist/cli-http.d.ts +38 -0
- package/dist/cli-http.js +286 -0
- package/dist/cli-init.d.ts +27 -0
- package/dist/cli-init.js +430 -0
- package/dist/cli-routes.d.ts +15 -0
- package/dist/cli-routes.js +52 -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 +674 -270
- package/package.json +7 -2
- package/src/cli-config.ts +283 -0
- package/src/cli-dx.ts +33 -0
- package/src/cli-http.ts +273 -0
- package/src/cli-init.ts +430 -0
- package/src/cli-routes.ts +72 -0
- package/src/cli-runtime.ts +30 -0
- package/src/cli-web-auth.ts +148 -0
- package/src/cli.ts +431 -295
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Browser approval with outbound-only polling, suitable for local and SSH terminals. */
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { CliError } from "./cli-http.js";
|
|
5
|
+
import { normalizeBaseURL, validateApiKey } from "./cli-config.js";
|
|
6
|
+
/** No shell interpolation, URL handlers, or incoming callback listener. */
|
|
7
|
+
export async function openBrowser(url) {
|
|
8
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "rundll32.exe" : "xdg-open";
|
|
9
|
+
const args = process.platform === "win32" ? ["url.dll,FileProtocolHandler", url] : [url];
|
|
10
|
+
return new Promise(resolve => {
|
|
11
|
+
try {
|
|
12
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore", shell: false });
|
|
13
|
+
child.once("error", () => resolve(false));
|
|
14
|
+
child.once("spawn", () => { child.unref(); resolve(true); });
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
resolve(false);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function invalidResponse() {
|
|
22
|
+
throw new CliError("invalid_response", "The API returned an invalid browser authorization response", 1);
|
|
23
|
+
}
|
|
24
|
+
function verificationURLs(data, baseURL) {
|
|
25
|
+
const code = data.user_code;
|
|
26
|
+
if (typeof code !== "string" || !/^[A-Za-z0-9-]{6,24}$/.test(code))
|
|
27
|
+
invalidResponse();
|
|
28
|
+
if (typeof data.expires_in !== "number" || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || data.expires_in > 3600)
|
|
29
|
+
invalidResponse();
|
|
30
|
+
let plain;
|
|
31
|
+
let complete;
|
|
32
|
+
try {
|
|
33
|
+
plain = new URL(String(data.verification_uri));
|
|
34
|
+
complete = new URL(String(data.verification_uri_complete));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
invalidResponse();
|
|
38
|
+
}
|
|
39
|
+
// Never hand a custom protocol or a credential-bearing URL to the OS browser launcher.
|
|
40
|
+
try {
|
|
41
|
+
normalizeBaseURL(plain.origin);
|
|
42
|
+
normalizeBaseURL(complete.origin);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
invalidResponse();
|
|
46
|
+
}
|
|
47
|
+
if (plain.username || plain.password || plain.search || plain.hash || plain.pathname !== "/cli/login"
|
|
48
|
+
|| complete.username || complete.password || complete.hash || complete.origin !== plain.origin
|
|
49
|
+
|| complete.pathname !== "/cli/login" || complete.searchParams.get("user_code") !== code
|
|
50
|
+
|| [...complete.searchParams.keys()].some(key => key !== "user_code")
|
|
51
|
+
|| complete.searchParams.getAll("user_code").length !== 1)
|
|
52
|
+
invalidResponse();
|
|
53
|
+
if (new URL(baseURL).origin === "https://api.pyai.com" && plain.origin !== "https://console.pyai.com")
|
|
54
|
+
invalidResponse();
|
|
55
|
+
return { event: "authorization_required", verification_uri: plain.href,
|
|
56
|
+
verification_uri_complete: complete.href, user_code: code, expires_in: data.expires_in };
|
|
57
|
+
}
|
|
58
|
+
function redact(error, secrets) {
|
|
59
|
+
if (!(error instanceof CliError))
|
|
60
|
+
return error;
|
|
61
|
+
const safe = (value) => {
|
|
62
|
+
if (typeof value === "string")
|
|
63
|
+
return secrets.reduce((v, s) => s ? v.split(s).join("[REDACTED]").split(encodeURIComponent(s)).join("[REDACTED]") : v, value);
|
|
64
|
+
if (Array.isArray(value))
|
|
65
|
+
return value.map(safe);
|
|
66
|
+
if (value && typeof value === "object")
|
|
67
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, safe(v)]));
|
|
68
|
+
return value;
|
|
69
|
+
};
|
|
70
|
+
return new CliError(safe(error.code), safe(error.message), error.exitCode, safe(error.details));
|
|
71
|
+
}
|
|
72
|
+
export async function browserLogin(options) {
|
|
73
|
+
const now = options.now ?? Date.now;
|
|
74
|
+
const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
|
|
75
|
+
const duration = options.timeoutMs ?? 600_000;
|
|
76
|
+
if (!Number.isFinite(duration) || duration <= 0 || duration > 3_600_000)
|
|
77
|
+
throw new CliError("invalid_arguments", "Login timeout must be between 0 and 3600 seconds", 2);
|
|
78
|
+
const started = now();
|
|
79
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
80
|
+
const secrets = [verifier];
|
|
81
|
+
options.onSecret?.(verifier);
|
|
82
|
+
const challenge = createHash("sha256").update(verifier).digest("base64url");
|
|
83
|
+
try {
|
|
84
|
+
let grant;
|
|
85
|
+
try {
|
|
86
|
+
grant = await options.http.json("POST", "/auth/cli/device", { auth: false,
|
|
87
|
+
timeoutMs: Math.min(duration, options.requestTimeoutMs ?? 30_000),
|
|
88
|
+
json: { client_name: "PyAI CLI", code_challenge: challenge, code_challenge_method: "S256" } });
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof CliError && error.details?.status === 404) {
|
|
92
|
+
throw new CliError("browser_login_unavailable", "Browser login is not available on this deployment yet. Use auth login --key-stdin or deploy the CLI login API and console page.", 1);
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
if (!grant || typeof grant !== "object" || typeof grant.device_code !== "string"
|
|
97
|
+
|| grant.device_code.length < 32 || grant.device_code.length > 512 || /[\s\x00-\x1f\x7f]/.test(grant.device_code))
|
|
98
|
+
invalidResponse();
|
|
99
|
+
secrets.push(grant.device_code);
|
|
100
|
+
options.onSecret?.(grant.device_code);
|
|
101
|
+
const notice = verificationURLs(grant, options.baseURL);
|
|
102
|
+
if (typeof grant.interval !== "number" || !Number.isFinite(grant.interval) || grant.interval < 1 || grant.interval > 60)
|
|
103
|
+
invalidResponse();
|
|
104
|
+
let interval = grant.interval * 1000;
|
|
105
|
+
const deadline = Math.min(started + duration, now() + notice.expires_in * 1000);
|
|
106
|
+
options.onAuthorization(notice);
|
|
107
|
+
if (!options.noBrowser && !(await (options.open ?? openBrowser)(notice.verification_uri_complete)))
|
|
108
|
+
options.onBrowserUnavailable?.();
|
|
109
|
+
for (;;) {
|
|
110
|
+
const remaining = deadline - now();
|
|
111
|
+
if (remaining <= interval) {
|
|
112
|
+
if (remaining > 0)
|
|
113
|
+
await sleep(remaining);
|
|
114
|
+
throw new CliError("login_timeout", "Browser login expired before completion. Run pyai auth login again.", 4);
|
|
115
|
+
}
|
|
116
|
+
await sleep(interval);
|
|
117
|
+
try {
|
|
118
|
+
const result = await options.http.json("POST", "/auth/cli/device/token", { auth: false,
|
|
119
|
+
timeoutMs: Math.min(deadline - now(), options.requestTimeoutMs ?? 30_000),
|
|
120
|
+
json: { device_code: grant.device_code, code_verifier: verifier } });
|
|
121
|
+
if (!result || typeof result.api_key !== "string")
|
|
122
|
+
invalidResponse();
|
|
123
|
+
secrets.push(result.api_key);
|
|
124
|
+
options.onSecret?.(result.api_key);
|
|
125
|
+
validateApiKey(result.api_key);
|
|
126
|
+
if (!["key_id", "org_id", "project_id"].every(key => typeof result[key] === "string" && result[key].length > 0)
|
|
127
|
+
|| !["test", "live"].includes(result.environment) || !Array.isArray(result.scopes) || !result.scopes.every((s) => typeof s === "string")
|
|
128
|
+
|| typeof result.expires_at !== "number" || !Number.isFinite(result.expires_at) || result.expires_at <= now())
|
|
129
|
+
invalidResponse();
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
if (!(error instanceof CliError))
|
|
134
|
+
throw error;
|
|
135
|
+
// Only protocol-level pending responses are safe to poll again. A lost
|
|
136
|
+
// success is ambiguous: do not replay a potentially consumed grant.
|
|
137
|
+
if (error.details?.status === 400 && error.code === "authorization_pending")
|
|
138
|
+
continue;
|
|
139
|
+
if (error.details?.status === 400 && error.code === "slow_down") {
|
|
140
|
+
interval += 5000;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (error.code === "access_denied")
|
|
144
|
+
throw new CliError("access_denied", "Browser login was declined. No credentials were saved.", 3);
|
|
145
|
+
if (["expired_token", "invalid_grant"].includes(error.code))
|
|
146
|
+
throw new CliError(error.code, "This login request has expired or was already used. Run pyai auth login again.", 3);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
throw redact(error, secrets);
|
|
153
|
+
}
|
|
154
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,18 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
* pyai, CLI for the PyAI API: proves your key, the endpoint, and audio in one
|
|
4
|
-
* command (`smoke`) or runs a deeper diagnosis with remediation hints (`doctor`).
|
|
5
|
-
*
|
|
6
|
-
* Commands:
|
|
7
|
-
* pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
|
|
8
|
-
* pyai smoke [--tolerate-upstream] run models+voices+speak and report PASS/FAIL
|
|
9
|
-
* (--tolerate-upstream: transient 5xx/429 → WARN, not FAIL)
|
|
10
|
-
* pyai models list models
|
|
11
|
-
* pyai voices [--gender g --region r] list voices
|
|
12
|
-
* pyai speak --text T [--voice V] [--out f.wav]
|
|
13
|
-
* pyai transcribe --url U [--diarize] [--poll]
|
|
14
|
-
*
|
|
15
|
-
* Auth: PYAI_API_KEY env (or --api-key). Base URL: PYAI_BASE_URL (or --base-url).
|
|
16
|
-
* Zero deps, uses the bundled SDK.
|
|
17
|
-
*/
|
|
18
|
-
export {};
|
|
2
|
+
import "./cli-runtime.ts";
|