@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.
- package/AGENT_GUIDE.md +296 -0
- package/CLI.md +609 -0
- package/CLI.schema.json +1085 -0
- package/README.md +49 -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/dist/index.d.ts +206 -26
- package/dist/index.js +98 -12
- 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
- package/src/index.ts +259 -32
package/dist/cli.js
CHANGED
|
@@ -1,320 +1,724 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
2
|
+
import "./cli-runtime.js";
|
|
3
|
+
/** Human and automation CLI. Stdout is results; stderr is errors. */
|
|
4
|
+
import { createWriteStream, unlinkSync } from "node:fs";
|
|
5
|
+
import { readFile, link, rename, unlink, stat, lstat } from "node:fs/promises";
|
|
6
|
+
import { basename, dirname, resolve, join } from "node:path";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { Readable, Transform } from "node:stream";
|
|
9
|
+
import { pipeline } from "node:stream/promises";
|
|
10
|
+
import { CliHttp, CliError } from "./cli-http.js";
|
|
11
|
+
import { browserLogin } from "./cli-web-auth.js";
|
|
12
|
+
import { CliConfigError, resolveConfig, saveProfile, removeProfile, useProfile, listProfiles, validateApiKey, validateProfileName } from "./cli-config.js";
|
|
13
|
+
import { routes } from "./cli-routes.js";
|
|
14
|
+
import { aliases, shortFlags, recipes } from "./cli-dx.js";
|
|
15
|
+
import { CLI_TEMPLATES, scaffoldProject, CliInitError } from "./cli-init.js";
|
|
16
|
+
import { SPEECH_FORMATS, SPEECH_SAMPLE_RATES } from "./index.js";
|
|
17
|
+
const globals = {
|
|
18
|
+
"api-key": "string", "base-url": "string", profile: "string", json: "boolean",
|
|
19
|
+
timeout: "string", retries: "string", "dry-run": "boolean", help: "boolean", version: "boolean",
|
|
20
|
+
};
|
|
21
|
+
const bodyFlags = { data: "string", "idempotency-key": "string" };
|
|
22
|
+
const outputFlags = { out: "string", force: "boolean" };
|
|
23
|
+
const waitFlags = { "wait-timeout": "string", "poll-interval": "string" };
|
|
24
|
+
const commands = [
|
|
25
|
+
{ command: "auth login", description: "Sign in through the browser, or save an explicitly supplied API key", flags: { web: "boolean", "no-browser": "boolean", "login-timeout": "string", "key-stdin": "boolean" }, example: "pyai auth login --profile production" },
|
|
26
|
+
{ command: "auth sandbox", description: "Create a sandbox tenant and save its key in a profile" },
|
|
27
|
+
{ command: "auth status", description: "Inspect the active key with /v1/me" },
|
|
28
|
+
{ command: "auth logout", description: "Remove a saved local profile (does not revoke the key)" },
|
|
29
|
+
{ command: "profiles list", description: "List saved profiles without revealing keys" },
|
|
30
|
+
{ command: "profiles use", description: "Select the default profile", args: ["name"] },
|
|
31
|
+
{ command: "models list", description: "List available models" },
|
|
32
|
+
{ command: "voices list", description: "List voices", flags: { gender: "string", region: "string", language: "string", tier: "string", q: "string", source: "string" } },
|
|
33
|
+
{ command: "voices get", description: "Get one voice", args: ["id"] },
|
|
34
|
+
{ command: "speak", description: "Synthesize text or stdin to an audio file", args: ["text"], optionalArgs: true, flags: { text: "string", "text-file": "string", voice: "string", model: "string", format: "string", "sample-rate": "string", ...outputFlags }, example: 'pyai speak "Hello from PyAI" -o hello.wav' },
|
|
35
|
+
{ command: "transcribe", description: "Transcribe a local file, or submit an audio URL as a job", args: ["input"], optionalArgs: true, flags: { file: "string", url: "string", filename: "string", language: "string", "text-only": "boolean", diarize: "boolean", poll: "boolean", wait: "boolean", ...waitFlags, "idempotency-key": "string" }, example: "pyai transcribe call.wav --text-only" },
|
|
36
|
+
{ command: "dub", description: "Dub a file or URL: submit, wait, and save the audio", args: ["input"], optionalArgs: true, flags: { file: "string", filename: "string", url: "string", from: "string", to: "string", ...outputFlags, ...waitFlags, ...bodyFlags }, example: "pyai dub original.wav --from en --to hi -o dubbed.wav" },
|
|
37
|
+
{ command: "clones create", description: "Clone a voice from a reference audio file", flags: { file: "string", filename: "string", name: "string" } },
|
|
38
|
+
{ command: "dub create", description: "Submit a local file or URL for dubbing", flags: { file: "string", filename: "string", url: "string", language: "string", "source-language": "string", ...bodyFlags } },
|
|
39
|
+
{ command: "request", description: "Call a relative API route with JSON, including future endpoints", args: ["method", "path"], flags: { ...bodyFlags, ...outputFlags, query: "repeat" }, example: "pyai request GET /v1/me --json" },
|
|
40
|
+
{ command: "schema", description: "Discover CLI commands offline, or retrieve live OpenAPI", args: ["command"], optionalArgs: true, variadic: true, flags: { openapi: "boolean" }, example: "pyai schema agents create --json" },
|
|
41
|
+
{ command: "recipes", description: "Browse copyable workflows offline; never executes the examples", args: ["name"], optionalArgs: true, example: "pyai recipes speak" },
|
|
42
|
+
{ command: "init", description: "Create a new project with API context and request examples offline", args: ["directory"], flags: { template: "string" }, example: "pyai init voice-demo --template typescript" },
|
|
43
|
+
{ command: "smoke", description: "Check models, voices, and speech synthesis", flags: { "tolerate-upstream": "boolean" } },
|
|
44
|
+
{ command: "doctor", description: "Diagnose key, catalogs, and a Speak to Hear round-trip" },
|
|
45
|
+
...routes.map((r) => ({ command: r.command, description: r.description, args: r.id ? ["id"] : [], flags: {
|
|
46
|
+
...(r.body ? bodyFlags : {}), ...(r.binary ? outputFlags : {}), ...(r.wait ? waitFlags : {}),
|
|
47
|
+
...(r.command.endsWith(" list") || r.command === "amd calls" ? { limit: "string", cursor: "string" } : {}),
|
|
48
|
+
} })),
|
|
49
|
+
];
|
|
50
|
+
function usage(message) { throw new CliError("invalid_arguments", message, 2); }
|
|
51
|
+
function value(flags, name) { return typeof flags[name] === "string" ? flags[name] : undefined; }
|
|
52
|
+
function required(flags, name) { const v = value(flags, name); if (!v)
|
|
53
|
+
usage(`--${name} is required`); return v; }
|
|
54
|
+
function number(flags, name, fallback, min = 0, max = 86400) {
|
|
55
|
+
const input = value(flags, name);
|
|
56
|
+
const n = input === undefined ? fallback : Number(input);
|
|
57
|
+
if (!Number.isFinite(n) || n < min || n > max || input?.trim() === "")
|
|
58
|
+
usage(`--${name} must be a number from ${min} to ${max}`);
|
|
59
|
+
return n;
|
|
60
|
+
}
|
|
61
|
+
function parse(argv) {
|
|
62
|
+
const known = Object.assign(Object.create(null), globals);
|
|
63
|
+
for (const c of commands)
|
|
64
|
+
Object.assign(known, c.flags);
|
|
65
|
+
const flags = Object.create(null);
|
|
66
|
+
const words = [];
|
|
22
67
|
for (let i = 0; i < argv.length; i++) {
|
|
23
|
-
|
|
24
|
-
if (a
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
68
|
+
let a = argv[i];
|
|
69
|
+
if (a === "--") {
|
|
70
|
+
words.push(...argv.slice(i + 1));
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
a = shortFlags[a] ?? a;
|
|
74
|
+
if (!a.startsWith("--")) {
|
|
75
|
+
if (a.startsWith("-") && a !== "-")
|
|
76
|
+
usage(`Unknown option ${a}`);
|
|
77
|
+
words.push(a);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const equal = a.indexOf("=");
|
|
81
|
+
const key = a.slice(2, equal < 0 ? undefined : equal);
|
|
82
|
+
const type = known[key];
|
|
83
|
+
if (!type)
|
|
84
|
+
usage(`Unknown option --${key}; run pyai --help`);
|
|
85
|
+
if (type === "boolean") {
|
|
86
|
+
if (equal >= 0)
|
|
87
|
+
usage(`--${key} does not take a value`);
|
|
88
|
+
flags[key] = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const v = equal >= 0 ? a.slice(equal + 1) : argv[++i];
|
|
92
|
+
if (v === undefined || (equal < 0 && (v.startsWith("--") || v in shortFlags)))
|
|
93
|
+
usage(`--${key} requires a value`);
|
|
94
|
+
if (type === "repeat") {
|
|
95
|
+
const list = (flags[key] ?? []);
|
|
96
|
+
list.push(v);
|
|
97
|
+
flags[key] = list;
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
if (flags[key] !== undefined)
|
|
101
|
+
usage(`--${key} may only be supplied once`);
|
|
102
|
+
flags[key] = v;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (words[0] === "help") {
|
|
106
|
+
flags.help = true;
|
|
107
|
+
words.shift();
|
|
108
|
+
}
|
|
109
|
+
if (words[0] && aliases[words[0]])
|
|
110
|
+
words.splice(0, 1, ...aliases[words[0]].split(" "));
|
|
111
|
+
if (["models", "voices", "profiles"].includes(words[0] ?? "") && words.length === 1)
|
|
112
|
+
words.push("list");
|
|
113
|
+
const spec = [...commands].sort((a, b) => b.command.length - a.command.length).find(c => c.command.split(" ").every((w, i) => words[i] === w));
|
|
114
|
+
if (!spec) {
|
|
115
|
+
if (words.length && !flags.help)
|
|
116
|
+
usage(`Unknown command ${words.join(" ")}; run pyai --help`);
|
|
117
|
+
return { flags, args: words };
|
|
118
|
+
}
|
|
119
|
+
const args = words.slice(spec.command.split(" ").length);
|
|
120
|
+
const expected = spec.args?.length ?? 0;
|
|
121
|
+
if (!flags.help && ((!spec.optionalArgs && args.length < expected) || (!spec.variadic && args.length > expected)))
|
|
122
|
+
usage(`Usage: pyai ${spec.command} ${(spec.args ?? []).map(x => spec.optionalArgs ? `[${x}]` : `<${x}>`).join(" ")}`);
|
|
123
|
+
for (const k of Object.keys(flags))
|
|
124
|
+
if (!(k in globals) && !(k in (spec.flags ?? {})))
|
|
125
|
+
usage(`--${k} is not supported by ${spec.command}`);
|
|
126
|
+
if (!flags.help && args.length && ["speak", "transcribe", "dub"].includes(spec.command)) {
|
|
127
|
+
if (spec.command === "speak") {
|
|
128
|
+
if (flags.text !== undefined || flags["text-file"] !== undefined)
|
|
129
|
+
usage("Choose positional text, --text, or --text-file, not multiple inputs");
|
|
130
|
+
flags.text = args[0];
|
|
34
131
|
}
|
|
35
132
|
else {
|
|
36
|
-
flags.
|
|
133
|
+
if (flags.file !== undefined || flags.url !== undefined)
|
|
134
|
+
usage("Choose a positional input, --file, or --url, not multiple inputs");
|
|
135
|
+
flags[/^https?:\/\//i.test(args[0]) ? "url" : "file"] = args[0];
|
|
37
136
|
}
|
|
38
137
|
}
|
|
39
|
-
return flags;
|
|
138
|
+
return { spec, flags, args };
|
|
40
139
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
140
|
+
const secrets = new Set();
|
|
141
|
+
const optionArgs = process.argv.slice(2, process.argv.indexOf("--", 2) < 0 ? undefined : process.argv.indexOf("--", 2));
|
|
142
|
+
const jsonMode = optionArgs.includes("--json") || optionArgs.includes("-j");
|
|
143
|
+
function redact(input, redactFields = true) {
|
|
144
|
+
if (typeof input === "string") {
|
|
145
|
+
let s = input;
|
|
146
|
+
for (const secret of secrets)
|
|
147
|
+
s = s.split(secret).join("[REDACTED]");
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(input))
|
|
151
|
+
return input.map(v => redact(v, redactFields));
|
|
152
|
+
if (input && typeof input === "object")
|
|
153
|
+
return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, redactFields && /(?:api[_-]?key|authorization|password|secret|token)$/i.test(k) ? "[REDACTED]" : redact(v, redactFields)]));
|
|
154
|
+
return input;
|
|
155
|
+
}
|
|
156
|
+
function output(data, flags, human, redactFields = true) {
|
|
157
|
+
process.stdout.write(flags.json || !human ? `${JSON.stringify(redact(data, redactFields), null, flags.json ? 0 : 2)}\n` : `${redact(human)}\n`);
|
|
44
158
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
159
|
+
function transcriptOutput(data, flags, path) {
|
|
160
|
+
if (!flags["text-only"]) {
|
|
161
|
+
output(data, flags);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const result = data;
|
|
165
|
+
const text = result?.text ?? result?.result?.text;
|
|
166
|
+
if (typeof text !== "string") {
|
|
167
|
+
if (typeof result?.result_url === "string")
|
|
168
|
+
throw new CliError("transcript_offloaded", "Transcript is stored as an external result. Use jobs get with --json to retrieve its result_url.", 1, { path });
|
|
169
|
+
throw new CliError("invalid_response", "The API response did not contain transcript text", 1, { path });
|
|
49
170
|
}
|
|
50
|
-
|
|
171
|
+
process.stdout.write(`${redact(text)}${text.endsWith("\n") ? "" : "\n"}`);
|
|
51
172
|
}
|
|
52
|
-
function
|
|
53
|
-
process.
|
|
173
|
+
async function stdin() {
|
|
174
|
+
if (process.stdin.isTTY)
|
|
175
|
+
usage("Pipe input through stdin, or supply a file path");
|
|
176
|
+
const chunks = [];
|
|
177
|
+
let size = 0;
|
|
178
|
+
for await (const chunk of process.stdin) {
|
|
179
|
+
const b = Buffer.from(chunk);
|
|
180
|
+
size += b.length;
|
|
181
|
+
if (size > 128 * 1024 * 1024)
|
|
182
|
+
usage("stdin exceeds 128 MiB; use a file or hosted audio URL");
|
|
183
|
+
chunks.push(b);
|
|
184
|
+
}
|
|
185
|
+
return Buffer.concat(chunks);
|
|
54
186
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
187
|
+
async function inputFile(path) { return path === "-" ? stdin() : readFile(path); }
|
|
188
|
+
async function jsonInput(flags, objectOnly = false) {
|
|
189
|
+
const raw = required(flags, "data");
|
|
190
|
+
const text = raw.startsWith("@") ? (await inputFile(raw.slice(1))).toString("utf8") : raw;
|
|
191
|
+
let parsed;
|
|
192
|
+
try {
|
|
193
|
+
parsed = JSON.parse(text);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
usage("--data must contain valid JSON (inline, @file.json, or @-)");
|
|
197
|
+
}
|
|
198
|
+
if (objectOnly && (!parsed || typeof parsed !== "object" || Array.isArray(parsed)))
|
|
199
|
+
usage("--data must be a JSON object");
|
|
200
|
+
return parsed;
|
|
201
|
+
}
|
|
202
|
+
function headers(flags) { const key = value(flags, "idempotency-key"); return key ? { "Idempotency-Key": key } : {}; }
|
|
203
|
+
function safeId(id) { if (!id || id === "." || id === ".." || /[\x00-\x1f\x7f]/.test(id))
|
|
204
|
+
usage("Invalid resource id"); return encodeURIComponent(id); }
|
|
205
|
+
function withQuery(path, fields) {
|
|
206
|
+
const q = new URLSearchParams(Object.entries(fields).filter((kv) => kv[1] !== undefined));
|
|
207
|
+
return q.toString() ? `${path}${path.includes("?") ? "&" : "?"}${q}` : path;
|
|
208
|
+
}
|
|
209
|
+
function describeBody(body) {
|
|
210
|
+
if (!(body instanceof FormData))
|
|
211
|
+
return body;
|
|
212
|
+
const fields = {};
|
|
213
|
+
body.forEach((v, k) => { fields[k] = typeof v === "string" ? v : { filename: v.name, bytes: v.size, type: v.type }; });
|
|
214
|
+
return fields;
|
|
58
215
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
(--tolerate-upstream: transient 5xx/429 warn, don't fail)
|
|
65
|
-
pyai models list models
|
|
66
|
-
pyai voices [--gender g] [--region r] list voices
|
|
67
|
-
pyai speak --text T [--voice V] [--out f.wav]
|
|
68
|
-
pyai transcribe --url U [--diarize] [--poll]
|
|
69
|
-
|
|
70
|
-
Auth: PYAI_API_KEY (or --api-key). Base: PYAI_BASE_URL (or --base-url).`;
|
|
71
|
-
async function cmdModels(flags) {
|
|
72
|
-
const pyai = client(flags);
|
|
73
|
-
const res = await pyai.models.list();
|
|
74
|
-
out(JSON.stringify(res, null, 2));
|
|
216
|
+
function preview(flags, baseURL, method, path, body) {
|
|
217
|
+
if (!flags["dry-run"])
|
|
218
|
+
return false;
|
|
219
|
+
output({ dry_run: true, method, url: `${baseURL}${path}`, body: describeBody(body) ?? null, headers: headers(flags) }, flags);
|
|
220
|
+
return true;
|
|
75
221
|
}
|
|
76
|
-
async function
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
222
|
+
async function upload(flags, field) {
|
|
223
|
+
const file = required(flags, "file");
|
|
224
|
+
const bytes = await inputFile(file);
|
|
225
|
+
if (!bytes.length)
|
|
226
|
+
usage("Audio input is empty");
|
|
227
|
+
const form = new FormData();
|
|
228
|
+
form.set(field, new Blob([new Uint8Array(bytes)]), value(flags, "filename") ?? (file === "-" ? "audio.wav" : basename(file)));
|
|
229
|
+
return form;
|
|
80
230
|
}
|
|
81
|
-
async function
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
if (
|
|
85
|
-
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
231
|
+
async function dubForm(flags, language, sourceLanguage) {
|
|
232
|
+
if (!!value(flags, "file") === !!value(flags, "url"))
|
|
233
|
+
usage("Provide exactly one audio file or URL");
|
|
234
|
+
if (value(flags, "file") === "-" && value(flags, "data") === "@-")
|
|
235
|
+
usage("Audio and JSON cannot both consume stdin");
|
|
236
|
+
const form = value(flags, "file") ? await upload(flags, "file") : new FormData();
|
|
237
|
+
if (value(flags, "url"))
|
|
238
|
+
form.set("source_url", value(flags, "url"));
|
|
239
|
+
form.set("target_lang", language);
|
|
240
|
+
if (sourceLanguage)
|
|
241
|
+
form.set("source_lang", sourceLanguage);
|
|
242
|
+
if (flags.data !== undefined)
|
|
243
|
+
for (const [k, v] of Object.entries(await jsonInput(flags, true))) {
|
|
244
|
+
if (form.has(k) || ["file", "source_url"].includes(k))
|
|
245
|
+
usage(`Duplicate or reserved Dub field ${k}`);
|
|
246
|
+
form.set(k, typeof v === "string" ? v : JSON.stringify(v));
|
|
247
|
+
}
|
|
248
|
+
return form;
|
|
90
249
|
}
|
|
91
|
-
async function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
250
|
+
async function checkOutput(path, flags) {
|
|
251
|
+
if (path === "-" && flags.json)
|
|
252
|
+
usage("--out - cannot be combined with --json; use a file for a JSON receipt");
|
|
253
|
+
if (path === "-" && process.stdout.isTTY)
|
|
254
|
+
usage("Refusing binary audio on a terminal; use --out FILE or pipe stdout");
|
|
255
|
+
if (path === "-")
|
|
256
|
+
return;
|
|
257
|
+
const parent = await stat(dirname(resolve(path)));
|
|
258
|
+
if (!parent.isDirectory())
|
|
259
|
+
usage("Output parent must be a directory");
|
|
260
|
+
if (flags.force)
|
|
99
261
|
return;
|
|
100
|
-
|
|
101
|
-
await
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
262
|
+
try {
|
|
263
|
+
await lstat(path);
|
|
264
|
+
}
|
|
265
|
+
catch (e) {
|
|
266
|
+
if (e.code === "ENOENT")
|
|
105
267
|
return;
|
|
106
|
-
|
|
268
|
+
throw e;
|
|
107
269
|
}
|
|
108
|
-
|
|
270
|
+
usage(`Output already exists: ${path}; choose another path or pass --force`);
|
|
109
271
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
272
|
+
const pendingAudioFiles = new Set();
|
|
273
|
+
async function writeAudio(response, path, flags, metadata = {}) {
|
|
274
|
+
if (!response.body)
|
|
275
|
+
throw new CliError("empty_audio", "The API returned an empty audio body");
|
|
276
|
+
let bytes = 0;
|
|
277
|
+
const counter = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; callback(null, chunk); } });
|
|
278
|
+
const source = Readable.fromWeb(response.body);
|
|
279
|
+
if (path === "-") {
|
|
280
|
+
await pipeline(source, counter, process.stdout, { end: false });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
const destination = resolve(path);
|
|
284
|
+
const temporary = join(dirname(destination), `.pyai-${randomUUID()}.tmp`);
|
|
285
|
+
pendingAudioFiles.add(temporary);
|
|
286
|
+
try {
|
|
287
|
+
await pipeline(source, counter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
|
|
288
|
+
if (!bytes)
|
|
289
|
+
throw new CliError("empty_audio", "The API returned an empty audio body");
|
|
290
|
+
if (flags.force)
|
|
291
|
+
await rename(temporary, destination);
|
|
292
|
+
else
|
|
293
|
+
await link(temporary, destination);
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
await unlink(temporary).catch(() => { });
|
|
297
|
+
pendingAudioFiles.delete(temporary);
|
|
298
|
+
}
|
|
299
|
+
output({ ...metadata, path: destination, bytes, content_type: response.headers.get("content-type") }, flags, `wrote ${bytes} bytes -> ${path}`);
|
|
119
300
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
301
|
+
async function waitForJob(http, path, flags, success, failure) {
|
|
302
|
+
const timeout = number(flags, "wait-timeout", 120, 0.001) * 1000;
|
|
303
|
+
const interval = number(flags, "poll-interval", 2, 0.001, 60) * 1000;
|
|
304
|
+
const requestTimeout = number(flags, "timeout", 30, 0.001) * 1000;
|
|
305
|
+
const deadline = Date.now() + timeout;
|
|
306
|
+
for (;;) {
|
|
307
|
+
const remaining = deadline - Date.now();
|
|
308
|
+
if (remaining <= 0)
|
|
309
|
+
throw new CliError("job_timeout", "Job is still running; use its get or wait command to resume", 4, { path });
|
|
310
|
+
let job;
|
|
125
311
|
try {
|
|
126
|
-
|
|
312
|
+
job = await http.json("GET", path, { timeoutMs: Math.min(remaining, requestTimeout) });
|
|
127
313
|
}
|
|
128
|
-
catch (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
314
|
+
catch (e) {
|
|
315
|
+
if (e instanceof CliError && e.code === "timeout" && remaining <= requestTimeout)
|
|
316
|
+
throw new CliError("job_timeout", "Job is still running; use its get or wait command to resume", 4, { path });
|
|
317
|
+
if (e instanceof CliError)
|
|
318
|
+
throw new CliError(e.code, e.message, e.exitCode, { ...e.details, path });
|
|
319
|
+
throw e;
|
|
133
320
|
}
|
|
321
|
+
if (!job || typeof job.status !== "string")
|
|
322
|
+
throw new CliError("invalid_response", "Job response is missing status", 1, { path });
|
|
323
|
+
if (success.includes(job.status))
|
|
324
|
+
return job;
|
|
325
|
+
if (failure.includes(job.status))
|
|
326
|
+
throw new CliError("job_failed", "Job ended without a successful result", 1, { path, job });
|
|
327
|
+
await new Promise(r => setTimeout(r, Math.min(interval, Math.max(0, deadline - Date.now()))));
|
|
134
328
|
}
|
|
135
|
-
throw lastErr;
|
|
136
329
|
}
|
|
137
|
-
|
|
138
|
-
async function cmdSmoke(flags) {
|
|
139
|
-
const pyai = client(flags);
|
|
140
|
-
// CI/ops opt-in: a transient upstream blip (engine warming, a brief 503,
|
|
141
|
-
// rate-limit) should not red the build, it isn't the commit's fault. With this
|
|
142
|
-
// on, such failures are reported as WARN (exit 0); real key/scope/contract
|
|
143
|
-
// failures still FAIL (exit 1). Off by default so a developer running `pyai
|
|
144
|
-
// smoke` gets the strict, honest answer.
|
|
145
|
-
const tolerateUpstream = flags["tolerate-upstream"] === true || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1";
|
|
146
|
-
// Retry tuning is env-overridable (tests drive it fast; default rides brief blips).
|
|
147
|
-
const retryAttempts = Number(process.env.PYAI_SMOKE_RETRY_ATTEMPTS ?? 4);
|
|
148
|
-
const retryBaseMs = Number(process.env.PYAI_SMOKE_RETRY_BASE_MS ?? 800);
|
|
330
|
+
async function diagnostics(http, cmd, flags) {
|
|
149
331
|
const checks = [];
|
|
150
|
-
const
|
|
332
|
+
const tolerate = cmd === "smoke" && (flags["tolerate-upstream"] || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1");
|
|
333
|
+
const check = async (name, fn) => {
|
|
151
334
|
try {
|
|
152
|
-
checks.push({ name, status: "PASS", detail: await
|
|
335
|
+
checks.push({ name, status: "PASS", detail: await fn() });
|
|
153
336
|
}
|
|
154
337
|
catch (err) {
|
|
155
|
-
const
|
|
156
|
-
const status =
|
|
157
|
-
|
|
338
|
+
const e = err;
|
|
339
|
+
const status = Number(e.details?.status);
|
|
340
|
+
if (name === "key (/v1/me)" && status === 404) {
|
|
341
|
+
checks.push({ name, status: "SKIP", detail: "introspection route not on this deployment" });
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
const transient = status === 429 || status >= 500 || e.exitCode === 4;
|
|
345
|
+
const hint = status === 401 ? "Check PYAI_API_KEY or run pyai auth login." : status === 403 ? "Check required API key scopes in the console." : status === 404 ? "Check PYAI_BASE_URL and the endpoint." : status === 402 ? "Check account credit and key budget in the console." : status === 429 ? "Wait for Retry-After before trying again." : "Check endpoint availability; retry when healthy.";
|
|
346
|
+
checks.push({ name, status: tolerate && transient ? "WARN" : "FAIL", detail: e.message, hint });
|
|
158
347
|
}
|
|
159
348
|
};
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
});
|
|
164
|
-
await
|
|
165
|
-
const
|
|
166
|
-
|
|
349
|
+
if (cmd === "doctor")
|
|
350
|
+
await check("key (/v1/me)", async () => { const me = await http.json("GET", "/v1/me"); return `env=${me.environment ?? me.env ?? "unknown"}; scopes: ${(me.scopes ?? []).join(", ")}`; });
|
|
351
|
+
await check("models.list", async () => `${(await http.json("GET", "/v1/models")).data.length} models`);
|
|
352
|
+
await check("voices.list", async () => `${(await http.json("GET", "/v1/voices")).data.length} voices`);
|
|
353
|
+
await check(cmd === "doctor" ? "speak→hear round-trip" : "audio.speech", async () => {
|
|
354
|
+
const response = await http.request("POST", "/v1/audio/speech", { json: { model: "pyai-speak", input: "The quick brown fox jumps over the lazy dog." } });
|
|
355
|
+
const audio = await response.arrayBuffer();
|
|
356
|
+
if (!audio.byteLength)
|
|
357
|
+
throw new CliError("empty_audio", "Speech returned no audio");
|
|
358
|
+
if (cmd === "smoke")
|
|
359
|
+
return `${audio.byteLength} bytes of audio`;
|
|
360
|
+
const form = new FormData();
|
|
361
|
+
form.set("file", new Blob([audio], { type: "audio/wav" }), "doctor.wav");
|
|
362
|
+
form.set("model", "pyai-hear");
|
|
363
|
+
const tr = await http.json("POST", "/v1/audio/transcriptions", { body: form });
|
|
364
|
+
if (!tr.text?.trim())
|
|
365
|
+
throw new CliError("empty_transcript", "Transcription came back empty");
|
|
366
|
+
return `synth ${audio.byteLength} bytes → ${tr.text.slice(0, 80)}`;
|
|
167
367
|
});
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
for (const c of checks)
|
|
173
|
-
out(`${c.status} ${c.name}, ${c.detail}`);
|
|
174
|
-
const failed = checks.filter((c) => c.status === "FAIL");
|
|
175
|
-
const warned = checks.filter((c) => c.status === "WARN");
|
|
176
|
-
// GitHub Actions annotation: a tolerated blip is still surfaced in the run UI.
|
|
177
|
-
for (const c of warned)
|
|
178
|
-
out(`::warning title=PyAI smoke transient::${c.name}: ${c.detail}`);
|
|
179
|
-
if (failed.length === 0 && warned.length === 0) {
|
|
180
|
-
out("\nAll checks passed. Your key, the endpoint, and audio synthesis work.");
|
|
181
|
-
}
|
|
182
|
-
else if (failed.length === 0) {
|
|
183
|
-
out(`\n${warned.length} transient upstream issue(s) tolerated (self-healing engine blip), not failing the build.`);
|
|
184
|
-
}
|
|
368
|
+
const failed = checks.filter(c => c.status === "FAIL").length;
|
|
369
|
+
const warned = checks.filter(c => c.status === "WARN").length;
|
|
370
|
+
if (flags.json)
|
|
371
|
+
output({ ok: !failed, checks }, flags);
|
|
185
372
|
else {
|
|
186
|
-
|
|
187
|
-
|
|
373
|
+
for (const c of checks) {
|
|
374
|
+
process.stdout.write(`${redact(`${c.status.padEnd(4)} ${c.name}, ${c.detail}`)}\n`);
|
|
375
|
+
if (c.hint)
|
|
376
|
+
process.stdout.write(` ↳ ${c.hint}\n`);
|
|
377
|
+
}
|
|
378
|
+
if (cmd === "doctor")
|
|
379
|
+
process.stdout.write(failed ? `\nDiagnosis: ${failed} check(s) failed.\n` : "\nDiagnosis: healthy.\n");
|
|
380
|
+
else if (failed)
|
|
381
|
+
process.stdout.write("\nSome checks failed (see above).\n");
|
|
382
|
+
else if (warned) {
|
|
383
|
+
process.stdout.write(`\n${warned} transient upstream issue(s) tolerated.\n`);
|
|
384
|
+
for (const c of checks.filter(c => c.status === "WARN"))
|
|
385
|
+
process.stdout.write(`::warning title=PyAI smoke transient::${c.name}\n`);
|
|
386
|
+
}
|
|
387
|
+
else
|
|
388
|
+
process.stdout.write("\nAll checks passed.\n");
|
|
188
389
|
}
|
|
390
|
+
if (failed)
|
|
391
|
+
process.exitCode = 1;
|
|
189
392
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
return "Invalid or missing key, check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
|
|
197
|
-
case "forbidden":
|
|
198
|
-
return "Key is missing a required scope, add it to the key in the console.";
|
|
199
|
-
case "origin_not_allowed":
|
|
200
|
-
return "Publishable token origin not allow-listed, fix the allowed origins.";
|
|
201
|
-
case "credit_exhausted":
|
|
202
|
-
return "Out of prepaid credit, add credit, or use a pyai_test_ sandbox key.";
|
|
203
|
-
case "key_budget_exceeded":
|
|
204
|
-
return "Per-key monthly budget hit, raise the budget in the console.";
|
|
205
|
-
case "insufficient_quota":
|
|
206
|
-
return "Plan quota exhausted, upgrade your plan.";
|
|
207
|
-
case "rate_limit_exceeded":
|
|
208
|
-
return "Rate limited, back off and retry (honor Retry-After).";
|
|
209
|
-
case "concurrency_limit_exceeded":
|
|
210
|
-
return "Too many concurrent sessions, retry shortly.";
|
|
211
|
-
case "daily_cap_exceeded":
|
|
212
|
-
return "Daily cap reached, wait until it resets.";
|
|
213
|
-
default:
|
|
214
|
-
break;
|
|
393
|
+
async function main() {
|
|
394
|
+
const { spec, flags, args } = parse(process.argv.slice(2));
|
|
395
|
+
if (flags.version) {
|
|
396
|
+
const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
397
|
+
output({ version: pkg.version }, flags, pkg.version);
|
|
398
|
+
return;
|
|
215
399
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
400
|
+
if (!spec || flags.help) {
|
|
401
|
+
const group = args.join(" ");
|
|
402
|
+
const grouped = group ? commands.filter(c => c.command.startsWith(`${group} `)) : commands;
|
|
403
|
+
const visible = spec ? [spec, ...commands.filter(c => c.command.startsWith(`${spec.command} `))] : grouped.length ? grouped : commands;
|
|
404
|
+
if (flags.json)
|
|
405
|
+
output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
|
|
406
|
+
else if (!spec && !group)
|
|
407
|
+
process.stdout.write(`PyAI CLI — speech, agents, and automation\n\nStart here\n pyai login Sign in through your browser\n pyai speak "Hello from PyAI" -o hello.wav Turn text into audio\n pyai hear call.wav --text-only Print a transcript\n pyai dub call.wav --from en --to hi -o hi.wav\n Submit, wait, and save dubbed audio\n pyai init voice-demo --template typescript Create a starter project offline\n\nDiscover\n pyai recipes [name] Copyable workflows\n pyai help <command> Command options and examples\n pyai help all Every command\n pyai schema [command] -j Machine-readable command definitions\n\nGroups\n auth profiles models voices agents jobs clones design\n cast dub recap trace tools vocabulary amd\n\nShortcuts: login, logout, whoami, use, say (speak), hear (transcribe)\nOptions: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nGlobal: --base-url URL --api-key KEY --timeout SECONDS --retries N --dry-run\nCredentials: pyai login or PYAI_API_KEY. Run pyai doctor for a usage-consuming diagnostic.\n`);
|
|
408
|
+
else
|
|
409
|
+
process.stdout.write(`PyAI CLI — speech, agents, and automation\n\n${visible.map(c => ` pyai ${c.command}${(c.args ?? []).map(a => c.optionalArgs ? ` [${a}${c.variadic ? "..." : ""}]` : ` <${a}>`).join("")}\n ${c.description}${c.flags ? `\n Options: ${Object.keys(c.flags).map(k => `--${k}${c.flags[k] === "boolean" ? "" : " VALUE"}`).join(" ")}` : ""}${c.example ? `\n ${c.example}` : ""}`).join("\n")}\n\nGlobal: --profile NAME --json --timeout SECONDS --retries N --dry-run\n --base-url URL --api-key KEY --help --version\nShort flags: -o FILE -f FILE -t TEXT -p PROFILE -j (JSON)\nAuth: pyai login or PYAI_API_KEY. Discover: pyai schema --json\n`);
|
|
410
|
+
return;
|
|
227
411
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
412
|
+
const cmd = spec.command;
|
|
413
|
+
if (cmd === "schema" && flags.openapi && args.length)
|
|
414
|
+
usage("--openapi returns the complete live contract; omit the command filter");
|
|
415
|
+
if (cmd === "schema" && !flags.openapi) {
|
|
416
|
+
const filterWords = [...args];
|
|
417
|
+
if (filterWords[0] && aliases[filterWords[0]])
|
|
418
|
+
filterWords.splice(0, 1, ...aliases[filterWords[0]].split(" "));
|
|
419
|
+
const filter = filterWords.join(" ");
|
|
420
|
+
const matches = (c) => !filter || c.command === filter || c.command.startsWith(`${filter} `);
|
|
421
|
+
const selected = commands.filter(matches);
|
|
422
|
+
if (!selected.length)
|
|
423
|
+
usage(`Unknown command group ${filter}; run pyai --help`);
|
|
424
|
+
output({ schema_version: 1, name: "pyai", global_flags: globals, short_flags: shortFlags, aliases, commands: selected, routes: routes.filter(matches), templates: CLI_TEMPLATES, output: { success: "JSON result on stdout with --json; no progress mixed in", error: "JSON error on stderr with --json", authorization: "Browser login emits public authorization events as NDJSON on stderr while waiting", binary: "--out - streams bytes; cannot combine with --json", text: "transcribe --text-only prints transcript text; cannot combine with --json" }, exit_codes: { "0": "success", "1": "API or job failure", "2": "arguments or local configuration", "3": "authentication or permission", "4": "network or timeout", "130": "interrupted" } }, flags, undefined, false);
|
|
425
|
+
return;
|
|
232
426
|
}
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
427
|
+
if (cmd === "recipes") {
|
|
428
|
+
const recipe = args[0] ? recipes.find(r => r.name === args[0]) : undefined;
|
|
429
|
+
if (args[0] && !recipe)
|
|
430
|
+
usage(`Unknown recipe ${args[0]}; run pyai recipes`);
|
|
431
|
+
output(recipe ?? { recipes }, flags, recipe ? `${recipe.title}\n${recipe.description}\n\n${recipe.commands.map(c => ` ${c}`).join("\n")}\n\n${recipe.notes.join("\n")}` : `PyAI recipes — copy and adapt; nothing is executed\n\n${recipes.map(r => ` ${r.name.padEnd(12)}${r.title}`).join("\n")}\n\nRun pyai recipes <name> for commands, or add --json for automation.`);
|
|
432
|
+
return;
|
|
236
433
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
434
|
+
if (cmd === "init") {
|
|
435
|
+
const result = await scaffoldProject({ directory: args[0], template: value(flags, "template"), dryRun: flags["dry-run"] === true });
|
|
436
|
+
output(result, flags, `${result.created ? "Created" : "Would create"} ${result.template} starter at ${result.directory}\n\n${result.files.map(f => ` ${f}`).join("\n")}\n\n${result.next_steps.join("\n")}`);
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (cmd === "profiles list") {
|
|
440
|
+
output(await listProfiles(), flags);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
if (cmd === "profiles use") {
|
|
444
|
+
if (!flags["dry-run"])
|
|
445
|
+
await useProfile(args[0]);
|
|
446
|
+
output({ profile: args[0], dry_run: !!flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would select" : "Selected"} profile ${args[0]}`);
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
if (cmd === "auth login") {
|
|
450
|
+
const profile = validateProfileName(value(flags, "profile") ?? process.env.PYAI_PROFILE ?? "default");
|
|
451
|
+
if (flags["key-stdin"] && value(flags, "api-key"))
|
|
452
|
+
usage("Choose --key-stdin or --api-key");
|
|
453
|
+
const explicitKey = flags["key-stdin"] || flags["api-key"] !== undefined;
|
|
454
|
+
if (explicitKey && (flags.web || flags["no-browser"] || flags["login-timeout"] !== undefined))
|
|
455
|
+
usage("Browser login options cannot be combined with --key-stdin or --api-key");
|
|
456
|
+
if (!explicitKey) {
|
|
457
|
+
const { baseURL } = await resolveConfig({ baseURL: value(flags, "base-url"), profile, allowMissingProfile: true, ignoreApiKey: true });
|
|
458
|
+
const timeoutMs = number(flags, "timeout", 30, 0.001) * 1000;
|
|
459
|
+
const loginTimeoutMs = number(flags, "login-timeout", 600, 0.001, 3600) * 1000;
|
|
460
|
+
if (flags["dry-run"]) {
|
|
461
|
+
output({ dry_run: true, method: "POST", url: `${baseURL}/auth/cli/device`, profile, browser: !flags["no-browser"], authorization: "Approve the displayed code in the console; credentials are saved after approval" }, flags);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
const result = await browserLogin({ http: new CliHttp({ baseURL, timeoutMs, maxRetries: 0 }), baseURL,
|
|
465
|
+
noBrowser: flags["no-browser"] === true || (!!process.env.CI && !flags.web), timeoutMs: loginTimeoutMs, requestTimeoutMs: timeoutMs,
|
|
466
|
+
onSecret: key => secrets.add(key),
|
|
467
|
+
onAuthorization: notice => {
|
|
468
|
+
if (flags.json)
|
|
469
|
+
process.stderr.write(`${JSON.stringify(notice)}\n`);
|
|
470
|
+
else
|
|
471
|
+
process.stderr.write(`Open ${notice.verification_uri_complete}\nConfirm code ${notice.user_code} in the browser, then choose a project.\nWaiting for approval…\n`);
|
|
472
|
+
},
|
|
473
|
+
onBrowserUnavailable: () => {
|
|
474
|
+
process.stderr.write(flags.json ? `${JSON.stringify({ event: "browser_unavailable", message: "Open the verification link manually" })}\n` : "Could not launch a browser. Open the link above manually.\n");
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
await saveProfile(profile, { api_key: result.api_key, base_url: baseURL });
|
|
478
|
+
const { api_key: _key, ...metadata } = result;
|
|
479
|
+
output({ ...metadata, profile, base_url: baseURL, saved: true }, flags, `Signed in. Saved ${result.environment} credentials for profile ${profile}.`);
|
|
480
|
+
return;
|
|
257
481
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
482
|
+
const key = flags["key-stdin"] ? (await stdin()).toString("utf8").trim() : required(flags, "api-key");
|
|
483
|
+
secrets.add(key);
|
|
484
|
+
validateApiKey(key);
|
|
485
|
+
const { baseURL } = await resolveConfig({ apiKey: key, baseURL: value(flags, "base-url"), profile, allowMissingProfile: true });
|
|
486
|
+
if (!flags["dry-run"])
|
|
487
|
+
await saveProfile(profile, { api_key: key, base_url: baseURL });
|
|
488
|
+
output({ profile, base_url: baseURL, saved: !flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would save" : "Saved"} credentials for ${profile}`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
const sandbox = cmd === "auth sandbox";
|
|
492
|
+
const sandboxProfile = sandbox ? validateProfileName(value(flags, "profile") ?? process.env.PYAI_PROFILE ?? "sandbox") : undefined;
|
|
493
|
+
const config = await resolveConfig({ apiKey: value(flags, "api-key"), baseURL: value(flags, "base-url"), profile: sandboxProfile ?? value(flags, "profile"), allowMissingProfile: sandbox });
|
|
494
|
+
if (config.apiKey)
|
|
495
|
+
secrets.add(config.apiKey);
|
|
496
|
+
const retries = number(flags, "retries", 2, 0, 5);
|
|
497
|
+
if (!Number.isInteger(retries))
|
|
498
|
+
usage("--retries must be an integer");
|
|
499
|
+
const http = new CliHttp({ baseURL: config.baseURL, apiKey: config.apiKey, timeoutMs: number(flags, "timeout", 30, 0.001) * 1000, maxRetries: retries });
|
|
500
|
+
if (cmd === "auth logout") {
|
|
501
|
+
if (!flags["dry-run"])
|
|
502
|
+
await removeProfile(config.profile);
|
|
503
|
+
output({ profile: config.profile, removed: !flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would remove" : "Removed"} saved profile ${config.profile}`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (sandbox) {
|
|
507
|
+
if (preview(flags, config.baseURL, "POST", "/v1/sandbox/keys", {}))
|
|
508
|
+
return;
|
|
509
|
+
const result = await http.json("POST", "/v1/sandbox/keys", { auth: false, json: {} });
|
|
510
|
+
if (!result || typeof result.api_key !== "string")
|
|
511
|
+
throw new CliError("invalid_response", "Sandbox response did not contain an API key");
|
|
512
|
+
secrets.add(result.api_key);
|
|
513
|
+
validateApiKey(result.api_key);
|
|
514
|
+
const profile = sandboxProfile;
|
|
515
|
+
await saveProfile(profile, { api_key: result.api_key, base_url: config.baseURL });
|
|
516
|
+
const { api_key: _key, ...metadata } = result;
|
|
517
|
+
output({ ...metadata, profile, saved: true }, flags, `Sandbox ready. Saved credentials in profile ${profile}.`);
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
if (cmd === "smoke" || cmd === "doctor") {
|
|
521
|
+
if (flags["dry-run"]) {
|
|
522
|
+
output({ dry_run: true, command: cmd, requests: ["GET /v1/models", "GET /v1/voices", "POST /v1/audio/speech", ...(cmd === "doctor" ? ["GET /v1/me", "POST /v1/audio/transcriptions"] : [])] }, flags);
|
|
523
|
+
return;
|
|
261
524
|
}
|
|
525
|
+
await diagnostics(http, cmd, flags);
|
|
526
|
+
return;
|
|
262
527
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
528
|
+
if (cmd === "speak") {
|
|
529
|
+
if (flags.text === undefined && flags["text-file"] === undefined && !process.stdin.isTTY)
|
|
530
|
+
flags["text-file"] = "-";
|
|
531
|
+
if (!!value(flags, "text") === !!value(flags, "text-file"))
|
|
532
|
+
usage("Provide exactly one of --text or --text-file (use - for stdin)");
|
|
533
|
+
const text = value(flags, "text") ?? (await inputFile(required(flags, "text-file"))).toString("utf8");
|
|
534
|
+
if (!text.trim())
|
|
535
|
+
usage("Speech text is empty");
|
|
536
|
+
const format = value(flags, "format") ?? "wav";
|
|
537
|
+
if (!SPEECH_FORMATS.includes(format))
|
|
538
|
+
usage(`--format must be one of ${SPEECH_FORMATS.join(", ")}`);
|
|
539
|
+
const sampleRate = value(flags, "sample-rate") === undefined ? undefined : number(flags, "sample-rate", 24000, 8000, 48000);
|
|
540
|
+
if (sampleRate !== undefined && !SPEECH_SAMPLE_RATES.includes(sampleRate))
|
|
541
|
+
usage(`--sample-rate must be one of ${SPEECH_SAMPLE_RATES.join(", ")}`);
|
|
542
|
+
if (format.startsWith("g711_") && sampleRate !== undefined && sampleRate !== 8000)
|
|
543
|
+
usage("G.711 audio requires --sample-rate 8000");
|
|
544
|
+
const payload = { input: text, model: value(flags, "model") ?? "pyai-speak", voice: value(flags, "voice"), response_format: format, sample_rate: sampleRate };
|
|
545
|
+
const path = value(flags, "out") ?? `pyai-speak.${format.startsWith("g711_") ? "raw" : format}`;
|
|
546
|
+
await checkOutput(path, flags);
|
|
547
|
+
if (preview(flags, config.baseURL, "POST", "/v1/audio/speech", payload))
|
|
548
|
+
return;
|
|
549
|
+
await writeAudio(await http.request("POST", "/v1/audio/speech", { json: payload }), path, flags);
|
|
550
|
+
return;
|
|
281
551
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
552
|
+
if (cmd === "transcribe") {
|
|
553
|
+
if (flags["text-only"] && flags.json)
|
|
554
|
+
usage("--text-only cannot be combined with --json");
|
|
555
|
+
if (!!value(flags, "file") === !!value(flags, "url"))
|
|
556
|
+
usage("Provide exactly one of --file or --url");
|
|
557
|
+
const isJob = !!value(flags, "url");
|
|
558
|
+
if (flags["text-only"] && isJob && !flags.wait && !flags.poll)
|
|
559
|
+
usage("--text-only with a URL requires --wait");
|
|
560
|
+
if (!isJob && (flags.diarize || flags.wait || flags.poll || flags["idempotency-key"]))
|
|
561
|
+
usage("--diarize, --wait, --poll and --idempotency-key require --url (async jobs)");
|
|
562
|
+
const path = isJob ? "/v1/transcription/jobs" : "/v1/audio/transcriptions";
|
|
563
|
+
const body = isJob ? { audio_url: value(flags, "url"), diarize: flags.diarize === true, ...(value(flags, "language") ? { language: value(flags, "language") } : {}) } : await upload(flags, "file");
|
|
564
|
+
if (body instanceof FormData) {
|
|
565
|
+
body.set("model", "pyai-hear");
|
|
566
|
+
if (value(flags, "language"))
|
|
567
|
+
body.set("language", value(flags, "language"));
|
|
568
|
+
}
|
|
569
|
+
if (flags.wait || flags.poll) {
|
|
570
|
+
number(flags, "wait-timeout", 120, 0.001);
|
|
571
|
+
number(flags, "poll-interval", 2, 0.001, 60);
|
|
572
|
+
}
|
|
573
|
+
if (preview(flags, config.baseURL, "POST", path, body))
|
|
574
|
+
return;
|
|
575
|
+
const result = await http.json("POST", path, { ...(body instanceof FormData ? { body } : { json: body }), headers: headers(flags) });
|
|
576
|
+
if (isJob && (flags.wait || flags.poll)) {
|
|
577
|
+
if (!result || typeof result.job_id !== "string")
|
|
578
|
+
throw new CliError("invalid_response", "Job response is missing job_id");
|
|
579
|
+
const completed = await waitForJob(http, `${path}/${safeId(result.job_id)}`, flags, ["completed"], ["failed", "cancelled"]);
|
|
580
|
+
transcriptOutput(completed, flags, `${path}/${safeId(result.job_id)}`);
|
|
581
|
+
}
|
|
582
|
+
else
|
|
583
|
+
transcriptOutput(result, flags);
|
|
584
|
+
return;
|
|
285
585
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
586
|
+
if (cmd === "dub") {
|
|
587
|
+
const language = required(flags, "to");
|
|
588
|
+
const out = value(flags, "out") ?? "pyai-dub.wav";
|
|
589
|
+
await checkOutput(out, flags);
|
|
590
|
+
number(flags, "wait-timeout", 120, 0.001);
|
|
591
|
+
number(flags, "poll-interval", 2, 0.001, 60);
|
|
592
|
+
const body = await dubForm(flags, language, value(flags, "from"));
|
|
593
|
+
if (flags["dry-run"]) {
|
|
594
|
+
output({ dry_run: true, command: "dub", method: "POST", url: `${config.baseURL}/v1/dub`, body: describeBody(body), headers: headers(flags), out, steps: ["submit", "wait for done", "download audio"], wait_timeout_seconds: number(flags, "wait-timeout", 120) }, flags);
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const created = await http.json("POST", "/v1/dub", { body, headers: headers(flags) });
|
|
598
|
+
if (!created || typeof created.job_id !== "string" || !created.job_id)
|
|
599
|
+
throw new CliError("invalid_response", "Dub response is missing job_id");
|
|
600
|
+
const path = `/v1/dub/jobs/${safeId(created.job_id)}`;
|
|
601
|
+
if (!flags.json)
|
|
602
|
+
process.stderr.write(`Dub job ${redact(created.job_id)} submitted. Waiting for audio…\n`);
|
|
603
|
+
try {
|
|
604
|
+
await waitForJob(http, path, flags, ["done"], ["error"]);
|
|
605
|
+
await writeAudio(await http.request("GET", `${path}/audio`), out, flags, { job_id: created.job_id, status: "done" });
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
if (error instanceof CliError)
|
|
609
|
+
throw new CliError(error.code, error.message, error.exitCode, { ...error.details, job_id: created.job_id, path });
|
|
610
|
+
throw new CliError("local_error", error.message, 2, { job_id: created.job_id, path });
|
|
611
|
+
}
|
|
612
|
+
return;
|
|
289
613
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
case "voices":
|
|
302
|
-
return cmdVoices(flags);
|
|
303
|
-
case "speak":
|
|
304
|
-
return cmdSpeak(flags);
|
|
305
|
-
case "transcribe":
|
|
306
|
-
return cmdTranscribe(flags);
|
|
307
|
-
case "help":
|
|
308
|
-
case undefined:
|
|
309
|
-
out(USAGE);
|
|
614
|
+
if (cmd === "clones create" || cmd === "dub create") {
|
|
615
|
+
let form;
|
|
616
|
+
if (cmd === "clones create") {
|
|
617
|
+
const name = required(flags, "name");
|
|
618
|
+
form = await upload(flags, "file");
|
|
619
|
+
form.set("name", name);
|
|
620
|
+
}
|
|
621
|
+
else
|
|
622
|
+
form = await dubForm(flags, required(flags, "language"), value(flags, "source-language"));
|
|
623
|
+
const path = cmd === "clones create" ? "/v1/voice/clones" : "/v1/dub";
|
|
624
|
+
if (preview(flags, config.baseURL, "POST", path, form))
|
|
310
625
|
return;
|
|
311
|
-
|
|
312
|
-
|
|
626
|
+
output(await http.json("POST", path, { body: form, headers: headers(flags) }), flags);
|
|
627
|
+
return;
|
|
313
628
|
}
|
|
629
|
+
if (cmd === "request") {
|
|
630
|
+
const method = args[0].toUpperCase();
|
|
631
|
+
if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].includes(method))
|
|
632
|
+
usage("Unsupported HTTP method");
|
|
633
|
+
let path = args[1];
|
|
634
|
+
if (!/^\/(?!\/)/.test(path) || path.includes("\\") || /[\x00-\x20\x7f]/.test(path))
|
|
635
|
+
usage("Use a relative API path such as /v1/models");
|
|
636
|
+
const q = new URLSearchParams();
|
|
637
|
+
for (const item of (flags.query ?? [])) {
|
|
638
|
+
const i = item.indexOf("=");
|
|
639
|
+
if (i < 1)
|
|
640
|
+
usage("--query requires name=value");
|
|
641
|
+
q.append(item.slice(0, i), item.slice(i + 1));
|
|
642
|
+
}
|
|
643
|
+
if (q.toString())
|
|
644
|
+
path += `${path.includes("?") ? "&" : "?"}${q}`;
|
|
645
|
+
http.validatePath(path);
|
|
646
|
+
const data = flags.data !== undefined ? await jsonInput(flags) : undefined;
|
|
647
|
+
if (data !== undefined && ["GET", "HEAD"].includes(method))
|
|
648
|
+
usage(`${method} does not accept --data`);
|
|
649
|
+
const out = value(flags, "out");
|
|
650
|
+
if (out)
|
|
651
|
+
await checkOutput(out, flags);
|
|
652
|
+
if (preview(flags, config.baseURL, method, path, data))
|
|
653
|
+
return;
|
|
654
|
+
if (out)
|
|
655
|
+
await writeAudio(await http.request(method, path, { json: data, headers: headers(flags) }), out, flags);
|
|
656
|
+
else
|
|
657
|
+
output(await http.json(method, path, { json: data, headers: headers(flags) }), flags);
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
const simplePaths = { "auth status": "/v1/me", "models list": "/v1/models", "voices list": "/v1/voices", "voices get": `/v1/voices/${args[0] ? safeId(args[0]) : ""}`, schema: "/openapi.json" };
|
|
661
|
+
if (cmd in simplePaths) {
|
|
662
|
+
const path = withQuery(simplePaths[cmd], Object.fromEntries(["gender", "region", "language", "tier", "q", "source"].map(k => [k, value(flags, k)])));
|
|
663
|
+
if (preview(flags, config.baseURL, "GET", path))
|
|
664
|
+
return;
|
|
665
|
+
output(await http.json("GET", path, { auth: cmd !== "schema" }), flags, undefined, cmd !== "schema");
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
const route = routes.find(r => r.command === cmd);
|
|
669
|
+
let path = route.path.replace("{id}", route.id ? safeId(args[0]) : "");
|
|
670
|
+
if (value(flags, "limit") !== undefined) {
|
|
671
|
+
const limit = number(flags, "limit", 20, 1, 100);
|
|
672
|
+
if (!Number.isInteger(limit))
|
|
673
|
+
usage("--limit must be an integer");
|
|
674
|
+
}
|
|
675
|
+
path = withQuery(path, { limit: value(flags, "limit"), cursor: value(flags, "cursor") });
|
|
676
|
+
const data = route.body ? await jsonInput(flags, true) : undefined;
|
|
677
|
+
const out = route.binary ? required(flags, "out") : undefined;
|
|
678
|
+
if (out)
|
|
679
|
+
await checkOutput(out, flags);
|
|
680
|
+
if (preview(flags, config.baseURL, route.method, path, data))
|
|
681
|
+
return;
|
|
682
|
+
if (route.wait)
|
|
683
|
+
output(await waitForJob(http, path, flags, route.wait.success, route.wait.failure), flags);
|
|
684
|
+
else if (out)
|
|
685
|
+
await writeAudio(await http.request(route.method, path, { json: data, headers: headers(flags) }), out, flags);
|
|
686
|
+
else
|
|
687
|
+
output(await http.json(route.method, path, { json: data, headers: headers(flags) }), flags);
|
|
314
688
|
}
|
|
315
|
-
|
|
316
|
-
if (err
|
|
317
|
-
|
|
689
|
+
for (const stream of [process.stdout, process.stderr])
|
|
690
|
+
stream.on("error", err => { if (err.code === "EPIPE")
|
|
691
|
+
process.exit(0); throw err; });
|
|
692
|
+
process.once("SIGINT", () => {
|
|
693
|
+
for (const path of pendingAudioFiles) {
|
|
694
|
+
try {
|
|
695
|
+
unlinkSync(path);
|
|
696
|
+
}
|
|
697
|
+
catch { /* Best effort cleanup on interruption. */ }
|
|
698
|
+
}
|
|
699
|
+
process.stderr.write(jsonMode ? `${JSON.stringify({ error: { code: "interrupted", message: "Interrupted" } })}\n` : "pyai: interrupted\n");
|
|
700
|
+
process.exit(130);
|
|
701
|
+
});
|
|
702
|
+
if (process.env.PYAI_API_KEY)
|
|
703
|
+
secrets.add(process.env.PYAI_API_KEY);
|
|
704
|
+
// Register explicit keys before parsing so even invalid-command errors cannot echo them.
|
|
705
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
706
|
+
const a = process.argv[i];
|
|
707
|
+
const key = a.startsWith("--api-key=") ? a.slice(10) : a === "--api-key" ? process.argv[i + 1] : undefined;
|
|
708
|
+
if (key)
|
|
709
|
+
secrets.add(key);
|
|
710
|
+
}
|
|
711
|
+
main().catch(err => {
|
|
712
|
+
const e = err instanceof CliError ? err : err instanceof CliConfigError || err instanceof CliInitError ? new CliError(err.code, err.message, 2) : new CliError("local_error", err.message, 2);
|
|
713
|
+
const detail = { code: e.code, message: e.message, ...e.details };
|
|
714
|
+
if (jsonMode)
|
|
715
|
+
process.stderr.write(`${JSON.stringify({ error: redact(detail) })}\n`);
|
|
716
|
+
else {
|
|
717
|
+
process.stderr.write(`pyai: ${redact(e.message)} (${redact(e.code)})\n`);
|
|
718
|
+
if (typeof e.details?.path === "string")
|
|
719
|
+
process.stderr.write(`Reference: ${redact(e.details.path)}\n`);
|
|
720
|
+
if (typeof e.details?.job_id === "string")
|
|
721
|
+
process.stderr.write(`Job ID: ${redact(e.details.job_id)}\n`);
|
|
318
722
|
}
|
|
319
|
-
|
|
723
|
+
process.exitCode = e.exitCode;
|
|
320
724
|
});
|