@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/dist/cli.js
CHANGED
|
@@ -1,320 +1,733 @@
|
|
|
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.query ?? {}), ...(r.confirm ? { confirm: "boolean" } : {}),
|
|
48
|
+
...(r.command.endsWith(" list") || r.command === "amd calls" ? { limit: "string", cursor: "string" } : {}),
|
|
49
|
+
} })),
|
|
50
|
+
];
|
|
51
|
+
function usage(message) { throw new CliError("invalid_arguments", message, 2); }
|
|
52
|
+
function value(flags, name) { return typeof flags[name] === "string" ? flags[name] : undefined; }
|
|
53
|
+
function required(flags, name) { const v = value(flags, name); if (!v)
|
|
54
|
+
usage(`--${name} is required`); return v; }
|
|
55
|
+
function number(flags, name, fallback, min = 0, max = 86400) {
|
|
56
|
+
const input = value(flags, name);
|
|
57
|
+
const n = input === undefined ? fallback : Number(input);
|
|
58
|
+
if (!Number.isFinite(n) || n < min || n > max || input?.trim() === "")
|
|
59
|
+
usage(`--${name} must be a number from ${min} to ${max}`);
|
|
60
|
+
return n;
|
|
61
|
+
}
|
|
62
|
+
function parse(argv) {
|
|
63
|
+
const known = Object.assign(Object.create(null), globals);
|
|
64
|
+
for (const c of commands)
|
|
65
|
+
Object.assign(known, c.flags);
|
|
66
|
+
const flags = Object.create(null);
|
|
67
|
+
const words = [];
|
|
22
68
|
for (let i = 0; i < argv.length; i++) {
|
|
23
|
-
|
|
24
|
-
if (a
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
69
|
+
let a = argv[i];
|
|
70
|
+
if (a === "--") {
|
|
71
|
+
words.push(...argv.slice(i + 1));
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
a = shortFlags[a] ?? a;
|
|
75
|
+
if (!a.startsWith("--")) {
|
|
76
|
+
if (a.startsWith("-") && a !== "-")
|
|
77
|
+
usage(`Unknown option ${a}`);
|
|
78
|
+
words.push(a);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const equal = a.indexOf("=");
|
|
82
|
+
const key = a.slice(2, equal < 0 ? undefined : equal);
|
|
83
|
+
const type = known[key];
|
|
84
|
+
if (!type)
|
|
85
|
+
usage(`Unknown option --${key}; run pyai --help`);
|
|
86
|
+
if (type === "boolean") {
|
|
87
|
+
if (equal >= 0)
|
|
88
|
+
usage(`--${key} does not take a value`);
|
|
89
|
+
flags[key] = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const v = equal >= 0 ? a.slice(equal + 1) : argv[++i];
|
|
93
|
+
if (v === undefined || (equal < 0 && (v.startsWith("--") || v in shortFlags)))
|
|
94
|
+
usage(`--${key} requires a value`);
|
|
95
|
+
if (type === "repeat") {
|
|
96
|
+
const list = (flags[key] ?? []);
|
|
97
|
+
list.push(v);
|
|
98
|
+
flags[key] = list;
|
|
34
99
|
}
|
|
35
100
|
else {
|
|
36
|
-
flags
|
|
101
|
+
if (flags[key] !== undefined)
|
|
102
|
+
usage(`--${key} may only be supplied once`);
|
|
103
|
+
flags[key] = v;
|
|
37
104
|
}
|
|
38
105
|
}
|
|
39
|
-
|
|
106
|
+
if (words[0] === "help") {
|
|
107
|
+
flags.help = true;
|
|
108
|
+
words.shift();
|
|
109
|
+
}
|
|
110
|
+
if (words[0] && aliases[words[0]])
|
|
111
|
+
words.splice(0, 1, ...aliases[words[0]].split(" "));
|
|
112
|
+
if (["models", "voices", "profiles"].includes(words[0] ?? "") && words.length === 1)
|
|
113
|
+
words.push("list");
|
|
114
|
+
const spec = [...commands].sort((a, b) => b.command.length - a.command.length).find(c => c.command.split(" ").every((w, i) => words[i] === w));
|
|
115
|
+
if (!spec) {
|
|
116
|
+
if (words.length && !flags.help)
|
|
117
|
+
usage(`Unknown command ${words.join(" ")}; run pyai --help`);
|
|
118
|
+
return { flags, args: words };
|
|
119
|
+
}
|
|
120
|
+
const args = words.slice(spec.command.split(" ").length);
|
|
121
|
+
const expected = spec.args?.length ?? 0;
|
|
122
|
+
if (!flags.help && ((!spec.optionalArgs && args.length < expected) || (!spec.variadic && args.length > expected)))
|
|
123
|
+
usage(`Usage: pyai ${spec.command} ${(spec.args ?? []).map(x => spec.optionalArgs ? `[${x}]` : `<${x}>`).join(" ")}`);
|
|
124
|
+
for (const k of Object.keys(flags))
|
|
125
|
+
if (!(k in globals) && !(k in (spec.flags ?? {})))
|
|
126
|
+
usage(`--${k} is not supported by ${spec.command}`);
|
|
127
|
+
if (!flags.help && args.length && ["speak", "transcribe", "dub"].includes(spec.command)) {
|
|
128
|
+
if (spec.command === "speak") {
|
|
129
|
+
if (flags.text !== undefined || flags["text-file"] !== undefined)
|
|
130
|
+
usage("Choose positional text, --text, or --text-file, not multiple inputs");
|
|
131
|
+
flags.text = args[0];
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
if (flags.file !== undefined || flags.url !== undefined)
|
|
135
|
+
usage("Choose a positional input, --file, or --url, not multiple inputs");
|
|
136
|
+
flags[/^https?:\/\//i.test(args[0]) ? "url" : "file"] = args[0];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return { spec, flags, args };
|
|
40
140
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
141
|
+
const secrets = new Set();
|
|
142
|
+
const optionArgs = process.argv.slice(2, process.argv.indexOf("--", 2) < 0 ? undefined : process.argv.indexOf("--", 2));
|
|
143
|
+
const jsonMode = optionArgs.includes("--json") || optionArgs.includes("-j");
|
|
144
|
+
function redact(input, redactFields = true) {
|
|
145
|
+
if (typeof input === "string") {
|
|
146
|
+
let s = input;
|
|
147
|
+
for (const secret of secrets)
|
|
148
|
+
s = s.split(secret).join("[REDACTED]");
|
|
149
|
+
return s;
|
|
150
|
+
}
|
|
151
|
+
if (Array.isArray(input))
|
|
152
|
+
return input.map(v => redact(v, redactFields));
|
|
153
|
+
if (input && typeof input === "object")
|
|
154
|
+
return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, redactFields && /(?:api[_-]?key|authorization|password|secret|token)$/i.test(k) ? "[REDACTED]" : redact(v, redactFields)]));
|
|
155
|
+
return input;
|
|
44
156
|
}
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
157
|
+
function output(data, flags, human, redactFields = true) {
|
|
158
|
+
process.stdout.write(flags.json || !human ? `${JSON.stringify(redact(data, redactFields), null, flags.json ? 0 : 2)}\n` : `${redact(human)}\n`);
|
|
159
|
+
}
|
|
160
|
+
function transcriptOutput(data, flags, path) {
|
|
161
|
+
if (!flags["text-only"]) {
|
|
162
|
+
output(data, flags);
|
|
163
|
+
return;
|
|
49
164
|
}
|
|
50
|
-
|
|
165
|
+
const result = data;
|
|
166
|
+
const text = result?.text ?? result?.result?.text;
|
|
167
|
+
if (typeof text !== "string") {
|
|
168
|
+
if (typeof result?.result_url === "string")
|
|
169
|
+
throw new CliError("transcript_offloaded", "Transcript is stored as an external result. Use jobs get with --json to retrieve its result_url.", 1, { path });
|
|
170
|
+
throw new CliError("invalid_response", "The API response did not contain transcript text", 1, { path });
|
|
171
|
+
}
|
|
172
|
+
process.stdout.write(`${redact(text)}${text.endsWith("\n") ? "" : "\n"}`);
|
|
51
173
|
}
|
|
52
|
-
function
|
|
53
|
-
process.
|
|
174
|
+
async function stdin() {
|
|
175
|
+
if (process.stdin.isTTY)
|
|
176
|
+
usage("Pipe input through stdin, or supply a file path");
|
|
177
|
+
const chunks = [];
|
|
178
|
+
let size = 0;
|
|
179
|
+
for await (const chunk of process.stdin) {
|
|
180
|
+
const b = Buffer.from(chunk);
|
|
181
|
+
size += b.length;
|
|
182
|
+
if (size > 128 * 1024 * 1024)
|
|
183
|
+
usage("stdin exceeds 128 MiB; use a file or hosted audio URL");
|
|
184
|
+
chunks.push(b);
|
|
185
|
+
}
|
|
186
|
+
return Buffer.concat(chunks);
|
|
54
187
|
}
|
|
55
|
-
function
|
|
56
|
-
|
|
57
|
-
|
|
188
|
+
async function inputFile(path) { return path === "-" ? stdin() : readFile(path); }
|
|
189
|
+
async function jsonInput(flags, objectOnly = false) {
|
|
190
|
+
const raw = required(flags, "data");
|
|
191
|
+
const text = raw.startsWith("@") ? (await inputFile(raw.slice(1))).toString("utf8") : raw;
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(text);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
usage("--data must contain valid JSON (inline, @file.json, or @-)");
|
|
198
|
+
}
|
|
199
|
+
if (objectOnly && (!parsed || typeof parsed !== "object" || Array.isArray(parsed)))
|
|
200
|
+
usage("--data must be a JSON object");
|
|
201
|
+
return parsed;
|
|
58
202
|
}
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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));
|
|
203
|
+
function headers(flags) { const key = value(flags, "idempotency-key"); return key ? { "Idempotency-Key": key } : {}; }
|
|
204
|
+
function safeId(id) { if (!id || id === "." || id === ".." || /[\x00-\x1f\x7f]/.test(id))
|
|
205
|
+
usage("Invalid resource id"); return encodeURIComponent(id); }
|
|
206
|
+
function withQuery(path, fields) {
|
|
207
|
+
const q = new URLSearchParams(Object.entries(fields).filter((kv) => kv[1] !== undefined));
|
|
208
|
+
return q.toString() ? `${path}${path.includes("?") ? "&" : "?"}${q}` : path;
|
|
75
209
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
210
|
+
function describeBody(body) {
|
|
211
|
+
if (!(body instanceof FormData))
|
|
212
|
+
return body;
|
|
213
|
+
const fields = {};
|
|
214
|
+
body.forEach((v, k) => { fields[k] = typeof v === "string" ? v : { filename: v.name, bytes: v.size, type: v.type }; });
|
|
215
|
+
return fields;
|
|
80
216
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
const audio = await pyai.audio.speech({ input: text, voice: flag(flags, "voice") });
|
|
87
|
-
const outPath = flag(flags, "out") ?? "pyai-speak.wav";
|
|
88
|
-
await writeFile(outPath, Buffer.from(audio));
|
|
89
|
-
out(`wrote ${Buffer.from(audio).byteLength} bytes -> ${outPath}`);
|
|
217
|
+
function preview(flags, baseURL, method, path, body) {
|
|
218
|
+
if (!flags["dry-run"])
|
|
219
|
+
return false;
|
|
220
|
+
output({ dry_run: true, method, url: `${baseURL}${path}`, body: describeBody(body) ?? null, headers: headers(flags) }, flags);
|
|
221
|
+
return true;
|
|
90
222
|
}
|
|
91
|
-
async function
|
|
92
|
-
const
|
|
93
|
-
const
|
|
94
|
-
if (!
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
223
|
+
async function upload(flags, field) {
|
|
224
|
+
const file = required(flags, "file");
|
|
225
|
+
const bytes = await inputFile(file);
|
|
226
|
+
if (!bytes.length)
|
|
227
|
+
usage("Audio input is empty");
|
|
228
|
+
const form = new FormData();
|
|
229
|
+
form.set(field, new Blob([new Uint8Array(bytes)]), value(flags, "filename") ?? (file === "-" ? "audio.wav" : basename(file)));
|
|
230
|
+
return form;
|
|
231
|
+
}
|
|
232
|
+
async function dubForm(flags, language, sourceLanguage) {
|
|
233
|
+
if (!!value(flags, "file") === !!value(flags, "url"))
|
|
234
|
+
usage("Provide exactly one audio file or URL");
|
|
235
|
+
if (value(flags, "file") === "-" && value(flags, "data") === "@-")
|
|
236
|
+
usage("Audio and JSON cannot both consume stdin");
|
|
237
|
+
const form = value(flags, "file") ? await upload(flags, "file") : new FormData();
|
|
238
|
+
if (value(flags, "url"))
|
|
239
|
+
form.set("source_url", value(flags, "url"));
|
|
240
|
+
form.set("target_lang", language);
|
|
241
|
+
if (sourceLanguage)
|
|
242
|
+
form.set("source_lang", sourceLanguage);
|
|
243
|
+
if (flags.data !== undefined)
|
|
244
|
+
for (const [k, v] of Object.entries(await jsonInput(flags, true))) {
|
|
245
|
+
if (form.has(k) || ["file", "source_url"].includes(k))
|
|
246
|
+
usage(`Duplicate or reserved Dub field ${k}`);
|
|
247
|
+
form.set(k, typeof v === "string" ? v : JSON.stringify(v));
|
|
248
|
+
}
|
|
249
|
+
return form;
|
|
250
|
+
}
|
|
251
|
+
async function checkOutput(path, flags) {
|
|
252
|
+
if (path === "-" && flags.json)
|
|
253
|
+
usage("--out - cannot be combined with --json; use a file for a JSON receipt");
|
|
254
|
+
if (path === "-" && process.stdout.isTTY)
|
|
255
|
+
usage("Refusing binary audio on a terminal; use --out FILE or pipe stdout");
|
|
256
|
+
if (path === "-")
|
|
99
257
|
return;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
258
|
+
const parent = await stat(dirname(resolve(path)));
|
|
259
|
+
if (!parent.isDirectory())
|
|
260
|
+
usage("Output parent must be a directory");
|
|
261
|
+
if (flags.force)
|
|
262
|
+
return;
|
|
263
|
+
try {
|
|
264
|
+
await lstat(path);
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
if (e.code === "ENOENT")
|
|
105
268
|
return;
|
|
106
|
-
|
|
269
|
+
throw e;
|
|
107
270
|
}
|
|
108
|
-
|
|
271
|
+
usage(`Output already exists: ${path}; choose another path or pass --force`);
|
|
109
272
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
273
|
+
const pendingAudioFiles = new Set();
|
|
274
|
+
async function writeAudio(response, path, flags, metadata = {}) {
|
|
275
|
+
if (!response.body)
|
|
276
|
+
throw new CliError("empty_audio", "The API returned an empty audio body");
|
|
277
|
+
let bytes = 0;
|
|
278
|
+
const counter = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; callback(null, chunk); } });
|
|
279
|
+
const source = Readable.fromWeb(response.body);
|
|
280
|
+
if (path === "-") {
|
|
281
|
+
await pipeline(source, counter, process.stdout, { end: false });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
const destination = resolve(path);
|
|
285
|
+
const temporary = join(dirname(destination), `.pyai-${randomUUID()}.tmp`);
|
|
286
|
+
pendingAudioFiles.add(temporary);
|
|
287
|
+
try {
|
|
288
|
+
await pipeline(source, counter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
|
|
289
|
+
if (!bytes)
|
|
290
|
+
throw new CliError("empty_audio", "The API returned an empty audio body");
|
|
291
|
+
if (flags.force)
|
|
292
|
+
await rename(temporary, destination);
|
|
293
|
+
else
|
|
294
|
+
await link(temporary, destination);
|
|
295
|
+
}
|
|
296
|
+
finally {
|
|
297
|
+
await unlink(temporary).catch(() => { });
|
|
298
|
+
pendingAudioFiles.delete(temporary);
|
|
299
|
+
}
|
|
300
|
+
output({ ...metadata, path: destination, bytes, content_type: response.headers.get("content-type") }, flags, `wrote ${bytes} bytes -> ${path}`);
|
|
119
301
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
302
|
+
async function waitForJob(http, path, flags, success, failure) {
|
|
303
|
+
const timeout = number(flags, "wait-timeout", 120, 0.001) * 1000;
|
|
304
|
+
const interval = number(flags, "poll-interval", 2, 0.001, 60) * 1000;
|
|
305
|
+
const requestTimeout = number(flags, "timeout", 30, 0.001) * 1000;
|
|
306
|
+
const deadline = Date.now() + timeout;
|
|
307
|
+
for (;;) {
|
|
308
|
+
const remaining = deadline - Date.now();
|
|
309
|
+
if (remaining <= 0)
|
|
310
|
+
throw new CliError("job_timeout", "Job is still running; use its get or wait command to resume", 4, { path });
|
|
311
|
+
let job;
|
|
125
312
|
try {
|
|
126
|
-
|
|
313
|
+
job = await http.json("GET", path, { timeoutMs: Math.min(remaining, requestTimeout) });
|
|
127
314
|
}
|
|
128
|
-
catch (
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
315
|
+
catch (e) {
|
|
316
|
+
if (e instanceof CliError && e.code === "timeout" && remaining <= requestTimeout)
|
|
317
|
+
throw new CliError("job_timeout", "Job is still running; use its get or wait command to resume", 4, { path });
|
|
318
|
+
if (e instanceof CliError)
|
|
319
|
+
throw new CliError(e.code, e.message, e.exitCode, { ...e.details, path });
|
|
320
|
+
throw e;
|
|
133
321
|
}
|
|
322
|
+
if (!job || typeof job.status !== "string")
|
|
323
|
+
throw new CliError("invalid_response", "Job response is missing status", 1, { path });
|
|
324
|
+
if (success.includes(job.status))
|
|
325
|
+
return job;
|
|
326
|
+
if (failure.includes(job.status))
|
|
327
|
+
throw new CliError("job_failed", "Job ended without a successful result", 1, { path, job });
|
|
328
|
+
await new Promise(r => setTimeout(r, Math.min(interval, Math.max(0, deadline - Date.now()))));
|
|
134
329
|
}
|
|
135
|
-
throw lastErr;
|
|
136
330
|
}
|
|
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);
|
|
331
|
+
async function diagnostics(http, cmd, flags) {
|
|
149
332
|
const checks = [];
|
|
150
|
-
const
|
|
333
|
+
const tolerate = cmd === "smoke" && (flags["tolerate-upstream"] || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1");
|
|
334
|
+
const check = async (name, fn) => {
|
|
151
335
|
try {
|
|
152
|
-
checks.push({ name, status: "PASS", detail: await
|
|
336
|
+
checks.push({ name, status: "PASS", detail: await fn() });
|
|
153
337
|
}
|
|
154
338
|
catch (err) {
|
|
155
|
-
const
|
|
156
|
-
const status =
|
|
157
|
-
|
|
339
|
+
const e = err;
|
|
340
|
+
const status = Number(e.details?.status);
|
|
341
|
+
if (name === "key (/v1/me)" && status === 404) {
|
|
342
|
+
checks.push({ name, status: "SKIP", detail: "introspection route not on this deployment" });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const transient = status === 429 || status >= 500 || e.exitCode === 4;
|
|
346
|
+
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.";
|
|
347
|
+
checks.push({ name, status: tolerate && transient ? "WARN" : "FAIL", detail: e.message, hint });
|
|
158
348
|
}
|
|
159
349
|
};
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
});
|
|
164
|
-
await
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
350
|
+
if (cmd === "doctor")
|
|
351
|
+
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(", ")}`; });
|
|
352
|
+
await check("models.list", async () => `${(await http.json("GET", "/v1/models")).data.length} models`);
|
|
353
|
+
await check("voices.list", async () => `${(await http.json("GET", "/v1/voices")).data.length} voices`);
|
|
354
|
+
await check(cmd === "doctor" ? "speak→hear round-trip" : "audio.speech", async () => {
|
|
355
|
+
const response = await http.request("POST", "/v1/audio/speech", { json: { model: "pyai-speak", input: "The quick brown fox jumps over the lazy dog." } });
|
|
356
|
+
const audio = await response.arrayBuffer();
|
|
357
|
+
if (!audio.byteLength)
|
|
358
|
+
throw new CliError("empty_audio", "Speech returned no audio");
|
|
359
|
+
if (cmd === "smoke")
|
|
360
|
+
return `${audio.byteLength} bytes of audio`;
|
|
361
|
+
const form = new FormData();
|
|
362
|
+
form.set("file", new Blob([audio], { type: "audio/wav" }), "doctor.wav");
|
|
363
|
+
form.set("model", "pyai-hear");
|
|
364
|
+
const tr = await http.json("POST", "/v1/audio/transcriptions", { body: form });
|
|
365
|
+
if (!tr.text?.trim())
|
|
366
|
+
throw new CliError("empty_transcript", "Transcription came back empty");
|
|
367
|
+
return `synth ${audio.byteLength} bytes → ${tr.text.slice(0, 80)}`;
|
|
171
368
|
});
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
}
|
|
369
|
+
const failed = checks.filter(c => c.status === "FAIL").length;
|
|
370
|
+
const warned = checks.filter(c => c.status === "WARN").length;
|
|
371
|
+
if (flags.json)
|
|
372
|
+
output({ ok: !failed, checks }, flags);
|
|
185
373
|
else {
|
|
186
|
-
|
|
187
|
-
|
|
374
|
+
for (const c of checks) {
|
|
375
|
+
process.stdout.write(`${redact(`${c.status.padEnd(4)} ${c.name}, ${c.detail}`)}\n`);
|
|
376
|
+
if (c.hint)
|
|
377
|
+
process.stdout.write(` ↳ ${c.hint}\n`);
|
|
378
|
+
}
|
|
379
|
+
if (cmd === "doctor")
|
|
380
|
+
process.stdout.write(failed ? `\nDiagnosis: ${failed} check(s) failed.\n` : "\nDiagnosis: healthy.\n");
|
|
381
|
+
else if (failed)
|
|
382
|
+
process.stdout.write("\nSome checks failed (see above).\n");
|
|
383
|
+
else if (warned) {
|
|
384
|
+
process.stdout.write(`\n${warned} transient upstream issue(s) tolerated.\n`);
|
|
385
|
+
for (const c of checks.filter(c => c.status === "WARN"))
|
|
386
|
+
process.stdout.write(`::warning title=PyAI smoke transient::${c.name}\n`);
|
|
387
|
+
}
|
|
388
|
+
else
|
|
389
|
+
process.stdout.write("\nAll checks passed.\n");
|
|
188
390
|
}
|
|
391
|
+
if (failed)
|
|
392
|
+
process.exitCode = 1;
|
|
189
393
|
}
|
|
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;
|
|
394
|
+
async function main() {
|
|
395
|
+
const { spec, flags, args } = parse(process.argv.slice(2));
|
|
396
|
+
if (flags.version) {
|
|
397
|
+
const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
|
|
398
|
+
output({ version: pkg.version }, flags, pkg.version);
|
|
399
|
+
return;
|
|
215
400
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
401
|
+
if (!spec || flags.help) {
|
|
402
|
+
const group = args.join(" ");
|
|
403
|
+
const grouped = group ? commands.filter(c => c.command.startsWith(`${group} `)) : commands;
|
|
404
|
+
const visible = spec ? [spec, ...commands.filter(c => c.command.startsWith(`${spec.command} `))] : grouped.length ? grouped : commands;
|
|
405
|
+
if (flags.json)
|
|
406
|
+
output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
|
|
407
|
+
else if (!spec && !group)
|
|
408
|
+
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 numbers calls omni\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`);
|
|
409
|
+
else
|
|
410
|
+
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`);
|
|
411
|
+
return;
|
|
227
412
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
413
|
+
const cmd = spec.command;
|
|
414
|
+
if (cmd === "schema" && flags.openapi && args.length)
|
|
415
|
+
usage("--openapi returns the complete live contract; omit the command filter");
|
|
416
|
+
if (cmd === "schema" && !flags.openapi) {
|
|
417
|
+
const filterWords = [...args];
|
|
418
|
+
if (filterWords[0] && aliases[filterWords[0]])
|
|
419
|
+
filterWords.splice(0, 1, ...aliases[filterWords[0]].split(" "));
|
|
420
|
+
const filter = filterWords.join(" ");
|
|
421
|
+
const matches = (c) => !filter || c.command === filter || c.command.startsWith(`${filter} `);
|
|
422
|
+
const selected = commands.filter(matches);
|
|
423
|
+
if (!selected.length)
|
|
424
|
+
usage(`Unknown command group ${filter}; run pyai --help`);
|
|
425
|
+
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);
|
|
426
|
+
return;
|
|
232
427
|
}
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
428
|
+
if (cmd === "recipes") {
|
|
429
|
+
const recipe = args[0] ? recipes.find(r => r.name === args[0]) : undefined;
|
|
430
|
+
if (args[0] && !recipe)
|
|
431
|
+
usage(`Unknown recipe ${args[0]}; run pyai recipes`);
|
|
432
|
+
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.`);
|
|
433
|
+
return;
|
|
236
434
|
}
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
435
|
+
if (cmd === "init") {
|
|
436
|
+
const result = await scaffoldProject({ directory: args[0], template: value(flags, "template"), dryRun: flags["dry-run"] === true });
|
|
437
|
+
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")}`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
if (cmd === "profiles list") {
|
|
441
|
+
output(await listProfiles(), flags);
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
if (cmd === "profiles use") {
|
|
445
|
+
if (!flags["dry-run"])
|
|
446
|
+
await useProfile(args[0]);
|
|
447
|
+
output({ profile: args[0], dry_run: !!flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would select" : "Selected"} profile ${args[0]}`);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (cmd === "auth login") {
|
|
451
|
+
const profile = validateProfileName(value(flags, "profile") ?? process.env.PYAI_PROFILE ?? "default");
|
|
452
|
+
if (flags["key-stdin"] && value(flags, "api-key"))
|
|
453
|
+
usage("Choose --key-stdin or --api-key");
|
|
454
|
+
const explicitKey = flags["key-stdin"] || flags["api-key"] !== undefined;
|
|
455
|
+
if (explicitKey && (flags.web || flags["no-browser"] || flags["login-timeout"] !== undefined))
|
|
456
|
+
usage("Browser login options cannot be combined with --key-stdin or --api-key");
|
|
457
|
+
if (!explicitKey) {
|
|
458
|
+
const { baseURL } = await resolveConfig({ baseURL: value(flags, "base-url"), profile, allowMissingProfile: true, ignoreApiKey: true });
|
|
459
|
+
const timeoutMs = number(flags, "timeout", 30, 0.001) * 1000;
|
|
460
|
+
const loginTimeoutMs = number(flags, "login-timeout", 600, 0.001, 3600) * 1000;
|
|
461
|
+
if (flags["dry-run"]) {
|
|
462
|
+
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);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const result = await browserLogin({ http: new CliHttp({ baseURL, timeoutMs, maxRetries: 0 }), baseURL,
|
|
466
|
+
noBrowser: flags["no-browser"] === true || (!!process.env.CI && !flags.web), timeoutMs: loginTimeoutMs, requestTimeoutMs: timeoutMs,
|
|
467
|
+
onSecret: key => secrets.add(key),
|
|
468
|
+
onAuthorization: notice => {
|
|
469
|
+
if (flags.json)
|
|
470
|
+
process.stderr.write(`${JSON.stringify(notice)}\n`);
|
|
471
|
+
else
|
|
472
|
+
process.stderr.write(`Open ${notice.verification_uri_complete}\nConfirm code ${notice.user_code} in the browser, then choose a project.\nWaiting for approval…\n`);
|
|
473
|
+
},
|
|
474
|
+
onBrowserUnavailable: () => {
|
|
475
|
+
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");
|
|
476
|
+
},
|
|
477
|
+
});
|
|
478
|
+
await saveProfile(profile, { api_key: result.api_key, base_url: baseURL });
|
|
479
|
+
const { api_key: _key, ...metadata } = result;
|
|
480
|
+
output({ ...metadata, profile, base_url: baseURL, saved: true }, flags, `Signed in. Saved ${result.environment} credentials for profile ${profile}.`);
|
|
481
|
+
return;
|
|
257
482
|
}
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
483
|
+
const key = flags["key-stdin"] ? (await stdin()).toString("utf8").trim() : required(flags, "api-key");
|
|
484
|
+
secrets.add(key);
|
|
485
|
+
validateApiKey(key);
|
|
486
|
+
const { baseURL } = await resolveConfig({ apiKey: key, baseURL: value(flags, "base-url"), profile, allowMissingProfile: true });
|
|
487
|
+
if (!flags["dry-run"])
|
|
488
|
+
await saveProfile(profile, { api_key: key, base_url: baseURL });
|
|
489
|
+
output({ profile, base_url: baseURL, saved: !flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would save" : "Saved"} credentials for ${profile}`);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
const sandbox = cmd === "auth sandbox";
|
|
493
|
+
const sandboxProfile = sandbox ? validateProfileName(value(flags, "profile") ?? process.env.PYAI_PROFILE ?? "sandbox") : undefined;
|
|
494
|
+
const config = await resolveConfig({ apiKey: value(flags, "api-key"), baseURL: value(flags, "base-url"), profile: sandboxProfile ?? value(flags, "profile"), allowMissingProfile: sandbox });
|
|
495
|
+
if (config.apiKey)
|
|
496
|
+
secrets.add(config.apiKey);
|
|
497
|
+
const retries = number(flags, "retries", 2, 0, 5);
|
|
498
|
+
if (!Number.isInteger(retries))
|
|
499
|
+
usage("--retries must be an integer");
|
|
500
|
+
const http = new CliHttp({ baseURL: config.baseURL, apiKey: config.apiKey, timeoutMs: number(flags, "timeout", 30, 0.001) * 1000, maxRetries: retries });
|
|
501
|
+
if (cmd === "auth logout") {
|
|
502
|
+
if (!flags["dry-run"])
|
|
503
|
+
await removeProfile(config.profile);
|
|
504
|
+
output({ profile: config.profile, removed: !flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would remove" : "Removed"} saved profile ${config.profile}`);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (sandbox) {
|
|
508
|
+
if (preview(flags, config.baseURL, "POST", "/v1/sandbox/keys", {}))
|
|
509
|
+
return;
|
|
510
|
+
const result = await http.json("POST", "/v1/sandbox/keys", { auth: false, json: {} });
|
|
511
|
+
if (!result || typeof result.api_key !== "string")
|
|
512
|
+
throw new CliError("invalid_response", "Sandbox response did not contain an API key");
|
|
513
|
+
secrets.add(result.api_key);
|
|
514
|
+
validateApiKey(result.api_key);
|
|
515
|
+
const profile = sandboxProfile;
|
|
516
|
+
await saveProfile(profile, { api_key: result.api_key, base_url: config.baseURL });
|
|
517
|
+
const { api_key: _key, ...metadata } = result;
|
|
518
|
+
output({ ...metadata, profile, saved: true }, flags, `Sandbox ready. Saved credentials in profile ${profile}.`);
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (cmd === "smoke" || cmd === "doctor") {
|
|
522
|
+
if (flags["dry-run"]) {
|
|
523
|
+
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);
|
|
524
|
+
return;
|
|
261
525
|
}
|
|
526
|
+
await diagnostics(http, cmd, flags);
|
|
527
|
+
return;
|
|
262
528
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
const
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
529
|
+
if (cmd === "speak") {
|
|
530
|
+
if (flags.text === undefined && flags["text-file"] === undefined && !process.stdin.isTTY)
|
|
531
|
+
flags["text-file"] = "-";
|
|
532
|
+
if (!!value(flags, "text") === !!value(flags, "text-file"))
|
|
533
|
+
usage("Provide exactly one of --text or --text-file (use - for stdin)");
|
|
534
|
+
const text = value(flags, "text") ?? (await inputFile(required(flags, "text-file"))).toString("utf8");
|
|
535
|
+
if (!text.trim())
|
|
536
|
+
usage("Speech text is empty");
|
|
537
|
+
const format = value(flags, "format") ?? "wav";
|
|
538
|
+
if (!SPEECH_FORMATS.includes(format))
|
|
539
|
+
usage(`--format must be one of ${SPEECH_FORMATS.join(", ")}`);
|
|
540
|
+
const sampleRate = value(flags, "sample-rate") === undefined ? undefined : number(flags, "sample-rate", 24000, 8000, 48000);
|
|
541
|
+
if (sampleRate !== undefined && !SPEECH_SAMPLE_RATES.includes(sampleRate))
|
|
542
|
+
usage(`--sample-rate must be one of ${SPEECH_SAMPLE_RATES.join(", ")}`);
|
|
543
|
+
if (format.startsWith("g711_") && sampleRate !== undefined && sampleRate !== 8000)
|
|
544
|
+
usage("G.711 audio requires --sample-rate 8000");
|
|
545
|
+
const payload = { input: text, model: value(flags, "model") ?? "pyai-speak", voice: value(flags, "voice"), response_format: format, sample_rate: sampleRate };
|
|
546
|
+
const path = value(flags, "out") ?? `pyai-speak.${format.startsWith("g711_") ? "raw" : format}`;
|
|
547
|
+
await checkOutput(path, flags);
|
|
548
|
+
if (preview(flags, config.baseURL, "POST", "/v1/audio/speech", payload))
|
|
549
|
+
return;
|
|
550
|
+
await writeAudio(await http.request("POST", "/v1/audio/speech", { json: payload }), path, flags);
|
|
551
|
+
return;
|
|
281
552
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
553
|
+
if (cmd === "transcribe") {
|
|
554
|
+
if (flags["text-only"] && flags.json)
|
|
555
|
+
usage("--text-only cannot be combined with --json");
|
|
556
|
+
if (!!value(flags, "file") === !!value(flags, "url"))
|
|
557
|
+
usage("Provide exactly one of --file or --url");
|
|
558
|
+
const isJob = !!value(flags, "url");
|
|
559
|
+
if (flags["text-only"] && isJob && !flags.wait && !flags.poll)
|
|
560
|
+
usage("--text-only with a URL requires --wait");
|
|
561
|
+
if (!isJob && (flags.diarize || flags.wait || flags.poll || flags["idempotency-key"]))
|
|
562
|
+
usage("--diarize, --wait, --poll and --idempotency-key require --url (async jobs)");
|
|
563
|
+
const path = isJob ? "/v1/transcription/jobs" : "/v1/audio/transcriptions";
|
|
564
|
+
const body = isJob ? { audio_url: value(flags, "url"), diarize: flags.diarize === true, ...(value(flags, "language") ? { language: value(flags, "language") } : {}) } : await upload(flags, "file");
|
|
565
|
+
if (body instanceof FormData) {
|
|
566
|
+
body.set("model", "pyai-hear");
|
|
567
|
+
if (value(flags, "language"))
|
|
568
|
+
body.set("language", value(flags, "language"));
|
|
569
|
+
}
|
|
570
|
+
if (flags.wait || flags.poll) {
|
|
571
|
+
number(flags, "wait-timeout", 120, 0.001);
|
|
572
|
+
number(flags, "poll-interval", 2, 0.001, 60);
|
|
573
|
+
}
|
|
574
|
+
if (preview(flags, config.baseURL, "POST", path, body))
|
|
575
|
+
return;
|
|
576
|
+
const result = await http.json("POST", path, { ...(body instanceof FormData ? { body } : { json: body }), headers: headers(flags) });
|
|
577
|
+
if (isJob && (flags.wait || flags.poll)) {
|
|
578
|
+
if (!result || typeof result.job_id !== "string")
|
|
579
|
+
throw new CliError("invalid_response", "Job response is missing job_id");
|
|
580
|
+
const completed = await waitForJob(http, `${path}/${safeId(result.job_id)}`, flags, ["completed"], ["failed", "cancelled"]);
|
|
581
|
+
transcriptOutput(completed, flags, `${path}/${safeId(result.job_id)}`);
|
|
582
|
+
}
|
|
583
|
+
else
|
|
584
|
+
transcriptOutput(result, flags);
|
|
585
|
+
return;
|
|
285
586
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
587
|
+
if (cmd === "dub") {
|
|
588
|
+
const language = required(flags, "to");
|
|
589
|
+
const out = value(flags, "out") ?? "pyai-dub.wav";
|
|
590
|
+
await checkOutput(out, flags);
|
|
591
|
+
number(flags, "wait-timeout", 120, 0.001);
|
|
592
|
+
number(flags, "poll-interval", 2, 0.001, 60);
|
|
593
|
+
const body = await dubForm(flags, language, value(flags, "from"));
|
|
594
|
+
if (flags["dry-run"]) {
|
|
595
|
+
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);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const created = await http.json("POST", "/v1/dub", { body, headers: headers(flags) });
|
|
599
|
+
if (!created || typeof created.job_id !== "string" || !created.job_id)
|
|
600
|
+
throw new CliError("invalid_response", "Dub response is missing job_id");
|
|
601
|
+
const path = `/v1/dub/jobs/${safeId(created.job_id)}`;
|
|
602
|
+
if (!flags.json)
|
|
603
|
+
process.stderr.write(`Dub job ${redact(created.job_id)} submitted. Waiting for audio…\n`);
|
|
604
|
+
try {
|
|
605
|
+
await waitForJob(http, path, flags, ["done"], ["error"]);
|
|
606
|
+
await writeAudio(await http.request("GET", `${path}/audio`), out, flags, { job_id: created.job_id, status: "done" });
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
if (error instanceof CliError)
|
|
610
|
+
throw new CliError(error.code, error.message, error.exitCode, { ...error.details, job_id: created.job_id, path });
|
|
611
|
+
throw new CliError("local_error", error.message, 2, { job_id: created.job_id, path });
|
|
612
|
+
}
|
|
613
|
+
return;
|
|
289
614
|
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
615
|
+
if (cmd === "clones create" || cmd === "dub create") {
|
|
616
|
+
let form;
|
|
617
|
+
if (cmd === "clones create") {
|
|
618
|
+
const name = required(flags, "name");
|
|
619
|
+
form = await upload(flags, "file");
|
|
620
|
+
form.set("name", name);
|
|
621
|
+
}
|
|
622
|
+
else
|
|
623
|
+
form = await dubForm(flags, required(flags, "language"), value(flags, "source-language"));
|
|
624
|
+
const path = cmd === "clones create" ? "/v1/voice/clones" : "/v1/dub";
|
|
625
|
+
if (preview(flags, config.baseURL, "POST", path, form))
|
|
626
|
+
return;
|
|
627
|
+
output(await http.json("POST", path, { body: form, headers: headers(flags) }), flags);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (cmd === "request") {
|
|
631
|
+
const method = args[0].toUpperCase();
|
|
632
|
+
if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].includes(method))
|
|
633
|
+
usage("Unsupported HTTP method");
|
|
634
|
+
let path = args[1];
|
|
635
|
+
if (!/^\/(?!\/)/.test(path) || path.includes("\\") || /[\x00-\x20\x7f]/.test(path))
|
|
636
|
+
usage("Use a relative API path such as /v1/models");
|
|
637
|
+
const q = new URLSearchParams();
|
|
638
|
+
for (const item of (flags.query ?? [])) {
|
|
639
|
+
const i = item.indexOf("=");
|
|
640
|
+
if (i < 1)
|
|
641
|
+
usage("--query requires name=value");
|
|
642
|
+
q.append(item.slice(0, i), item.slice(i + 1));
|
|
643
|
+
}
|
|
644
|
+
if (q.toString())
|
|
645
|
+
path += `${path.includes("?") ? "&" : "?"}${q}`;
|
|
646
|
+
http.validatePath(path);
|
|
647
|
+
const data = flags.data !== undefined ? await jsonInput(flags) : undefined;
|
|
648
|
+
if (data !== undefined && ["GET", "HEAD"].includes(method))
|
|
649
|
+
usage(`${method} does not accept --data`);
|
|
650
|
+
const out = value(flags, "out");
|
|
651
|
+
if (out)
|
|
652
|
+
await checkOutput(out, flags);
|
|
653
|
+
if (preview(flags, config.baseURL, method, path, data))
|
|
654
|
+
return;
|
|
655
|
+
if (out)
|
|
656
|
+
await writeAudio(await http.request(method, path, { json: data, headers: headers(flags) }), out, flags);
|
|
657
|
+
else
|
|
658
|
+
output(await http.json(method, path, { json: data, headers: headers(flags) }), flags);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
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" };
|
|
662
|
+
if (cmd in simplePaths) {
|
|
663
|
+
const path = withQuery(simplePaths[cmd], Object.fromEntries(["gender", "region", "language", "tier", "q", "source"].map(k => [k, value(flags, k)])));
|
|
664
|
+
if (preview(flags, config.baseURL, "GET", path))
|
|
310
665
|
return;
|
|
311
|
-
|
|
312
|
-
|
|
666
|
+
output(await http.json("GET", path, { auth: cmd !== "schema" }), flags, undefined, cmd !== "schema");
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
const route = routes.find(r => r.command === cmd);
|
|
670
|
+
if (route.confirm && !flags.confirm && !flags["dry-run"]) {
|
|
671
|
+
throw new CliError("needs_human", "Review the request and supply --confirm only after purchase or dialing is authorized.", 2, { docs_url: "https://pyai.com/agents/speech-calling.md", console_url: "https://console.pyai.com/telephony" });
|
|
313
672
|
}
|
|
673
|
+
if (route.idempotencyRequired) {
|
|
674
|
+
const key = required(flags, "idempotency-key");
|
|
675
|
+
if (!key.trim() || key.length > 255 || /[\r\n]/.test(key))
|
|
676
|
+
usage("--idempotency-key must be a non-empty header-safe string of at most 255 characters");
|
|
677
|
+
}
|
|
678
|
+
let path = route.path.replace("{id}", route.id ? safeId(args[0]) : "");
|
|
679
|
+
if (value(flags, "limit") !== undefined) {
|
|
680
|
+
const limit = number(flags, "limit", 20, 1, 100);
|
|
681
|
+
if (!Number.isInteger(limit))
|
|
682
|
+
usage("--limit must be an integer");
|
|
683
|
+
}
|
|
684
|
+
path = withQuery(path, { limit: value(flags, "limit"), cursor: value(flags, "cursor"), ...Object.fromEntries(Object.keys(route.query ?? {}).filter(k => flags[k] !== undefined).map(k => [k.replaceAll("-", "_"), String(flags[k])])) });
|
|
685
|
+
const data = route.body ? await jsonInput(flags, true) : undefined;
|
|
686
|
+
const out = route.binary ? required(flags, "out") : undefined;
|
|
687
|
+
if (out)
|
|
688
|
+
await checkOutput(out, flags);
|
|
689
|
+
if (preview(flags, config.baseURL, route.method, path, data))
|
|
690
|
+
return;
|
|
691
|
+
if (route.wait)
|
|
692
|
+
output(await waitForJob(http, path, flags, route.wait.success, route.wait.failure), flags);
|
|
693
|
+
else if (out)
|
|
694
|
+
await writeAudio(await http.request(route.method, path, { json: data, headers: headers(flags) }), out, flags);
|
|
695
|
+
else
|
|
696
|
+
output(await http.json(route.method, path, { json: data, headers: headers(flags) }), flags);
|
|
314
697
|
}
|
|
315
|
-
|
|
316
|
-
if (err
|
|
317
|
-
|
|
698
|
+
for (const stream of [process.stdout, process.stderr])
|
|
699
|
+
stream.on("error", err => { if (err.code === "EPIPE")
|
|
700
|
+
process.exit(0); throw err; });
|
|
701
|
+
process.once("SIGINT", () => {
|
|
702
|
+
for (const path of pendingAudioFiles) {
|
|
703
|
+
try {
|
|
704
|
+
unlinkSync(path);
|
|
705
|
+
}
|
|
706
|
+
catch { /* Best effort cleanup on interruption. */ }
|
|
707
|
+
}
|
|
708
|
+
process.stderr.write(jsonMode ? `${JSON.stringify({ error: { code: "interrupted", message: "Interrupted" } })}\n` : "pyai: interrupted\n");
|
|
709
|
+
process.exit(130);
|
|
710
|
+
});
|
|
711
|
+
if (process.env.PYAI_API_KEY)
|
|
712
|
+
secrets.add(process.env.PYAI_API_KEY);
|
|
713
|
+
// Register explicit keys before parsing so even invalid-command errors cannot echo them.
|
|
714
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
715
|
+
const a = process.argv[i];
|
|
716
|
+
const key = a.startsWith("--api-key=") ? a.slice(10) : a === "--api-key" ? process.argv[i + 1] : undefined;
|
|
717
|
+
if (key)
|
|
718
|
+
secrets.add(key);
|
|
719
|
+
}
|
|
720
|
+
main().catch(err => {
|
|
721
|
+
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);
|
|
722
|
+
const detail = { code: e.code, message: e.message, ...e.details };
|
|
723
|
+
if (jsonMode)
|
|
724
|
+
process.stderr.write(`${JSON.stringify({ error: redact(detail) })}\n`);
|
|
725
|
+
else {
|
|
726
|
+
process.stderr.write(`pyai: ${redact(e.message)} (${redact(e.code)})\n`);
|
|
727
|
+
if (typeof e.details?.path === "string")
|
|
728
|
+
process.stderr.write(`Reference: ${redact(e.details.path)}\n`);
|
|
729
|
+
if (typeof e.details?.job_id === "string")
|
|
730
|
+
process.stderr.write(`Job ID: ${redact(e.details.job_id)}\n`);
|
|
318
731
|
}
|
|
319
|
-
|
|
732
|
+
process.exitCode = e.exitCode;
|
|
320
733
|
});
|