@pyai/sdk 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,320 +1,724 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * pyai, CLI for the PyAI API: proves your key, the endpoint, and audio in one
4
- * command (`smoke`) or runs a deeper diagnosis with remediation hints (`doctor`).
5
- *
6
- * Commands:
7
- * pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
8
- * pyai smoke [--tolerate-upstream] run models+voices+speak and report PASS/FAIL
9
- * (--tolerate-upstream: transient 5xx/429 WARN, not FAIL)
10
- * pyai models list models
11
- * pyai voices [--gender g --region r] list voices
12
- * pyai speak --text T [--voice V] [--out f.wav]
13
- * pyai transcribe --url U [--diarize] [--poll]
14
- *
15
- * Auth: PYAI_API_KEY env (or --api-key). Base URL: PYAI_BASE_URL (or --base-url).
16
- * Zero deps, uses the bundled SDK.
17
- */
18
- import { writeFile } from "node:fs/promises";
19
- import PyAI, { PyAIError } from "./index.js";
20
- function parseArgs(argv) {
21
- const flags = { _: [] };
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
- const a = argv[i];
24
- if (a.startsWith("--")) {
25
- const key = a.slice(2);
26
- const next = argv[i + 1];
27
- if (next === undefined || next.startsWith("--")) {
28
- flags[key] = true;
29
- }
30
- else {
31
- flags[key] = next;
32
- i++;
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._.push(a);
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
- function flag(flags, key) {
42
- const v = flags[key];
43
- return typeof v === "string" ? v : undefined;
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 client(flags) {
46
- const apiKey = flag(flags, "api-key") ?? process.env.PYAI_API_KEY;
47
- if (!apiKey) {
48
- fail("No API key. Set PYAI_API_KEY or pass --api-key pyai_test_...");
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
- return new PyAI({ apiKey: apiKey, baseURL: flag(flags, "base-url") ?? process.env.PYAI_BASE_URL });
171
+ process.stdout.write(`${redact(text)}${text.endsWith("\n") ? "" : "\n"}`);
51
172
  }
52
- function out(msg) {
53
- process.stdout.write(`${msg}\n`);
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 fail(msg) {
56
- process.stderr.write(`pyai: ${msg}\n`);
57
- process.exit(1);
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
- const USAGE = `pyai, PyAI API CLI
60
-
61
- Usage:
62
- pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
63
- pyai smoke [--tolerate-upstream] run a key/endpoint/audio smoke test
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 cmdVoices(flags) {
77
- const pyai = client(flags);
78
- const res = await pyai.voices.list({ gender: flag(flags, "gender"), region: flag(flags, "region") });
79
- out(JSON.stringify(res, null, 2));
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 cmdSpeak(flags) {
82
- const pyai = client(flags);
83
- const text = flag(flags, "text");
84
- if (!text)
85
- fail("speak requires --text");
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}`);
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 cmdTranscribe(flags) {
92
- const pyai = client(flags);
93
- const url = flag(flags, "url");
94
- if (!url)
95
- fail("transcribe requires --url (an https audio URL)");
96
- const job = await pyai.transcriptionJobs.create({ audio_url: url, diarize: flags.diarize === true });
97
- out(`job ${job.job_id} (${job.status})`);
98
- if (flags.poll !== true)
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
- for (let i = 0; i < 60; i++) {
101
- await new Promise((r) => setTimeout(r, 2000));
102
- const j = await pyai.transcriptionJobs.get(job.job_id);
103
- if (j.status === "completed" || j.status === "failed" || j.status === "cancelled") {
104
- out(JSON.stringify(j, null, 2));
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
- out(`job ${job.job_id} still running after polling; check later.`);
270
+ usage(`Output already exists: ${path}; choose another path or pass --force`);
109
271
  }
110
- // Transient upstream conditions: a momentary engine/capacity blip or network
111
- // hiccup, NOT a key/scope/contract problem. 5xx = engine unhealthy (e.g. Speak
112
- // "503 service_unavailable"), 429 = rate/capacity, a non-PyAIError = network.
113
- // These self-heal; the others (401/403/404/400) are real and must fail loudly.
114
- const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);
115
- function isTransient(err) {
116
- if (err instanceof PyAIError)
117
- return TRANSIENT_STATUSES.has(err.status);
118
- return true; // network / timeout / unknown, worth a retry, never a hard fail on its own
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
- /** Retry `fn` on transient upstream errors with exponential backoff. Real
121
- * (non-transient) errors throw immediately, we never paper over a bad key. */
122
- async function withRetry(fn, attempts = 4, baseMs = 800) {
123
- let lastErr;
124
- for (let i = 0; i < attempts; i++) {
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
- return await fn();
312
+ job = await http.json("GET", path, { timeoutMs: Math.min(remaining, requestTimeout) });
127
313
  }
128
- catch (err) {
129
- lastErr = err;
130
- if (!isTransient(err) || i === attempts - 1)
131
- throw err;
132
- await new Promise((r) => setTimeout(r, baseMs * 2 ** i));
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
- /** The headline: prove key + endpoint + audio in one command. */
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 run = async (name, fn) => {
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 withRetry(fn, retryAttempts, retryBaseMs) });
335
+ checks.push({ name, status: "PASS", detail: await fn() });
153
336
  }
154
337
  catch (err) {
155
- const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : err.message;
156
- const status = isTransient(err) && tolerateUpstream ? "WARN" : "FAIL";
157
- checks.push({ name, status, detail });
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
- await run("models.list", async () => {
161
- const r = await pyai.models.list();
162
- return `${r.data.length} models`;
163
- });
164
- await run("voices.list", async () => {
165
- const r = await pyai.voices.list();
166
- return `${r.data.length} voices`;
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
- await run("audio.speech", async () => {
169
- const audio = await pyai.audio.speech({ input: "PyAI smoke test." });
170
- return `${Buffer.from(audio).byteLength} bytes of audio`;
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
- out("\nSome checks failed (see above).");
187
- process.exit(1);
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
- /** Turn an error into an actionable, code-first remediation hint. */
191
- function remediation(err) {
192
- if (!(err instanceof PyAIError))
193
- return err?.message ?? String(err);
194
- switch (err.code) {
195
- case "unauthorized":
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
- switch (err.status) {
217
- case 401:
218
- return "Invalid or missing key, check PYAI_API_KEY.";
219
- case 403:
220
- return "Forbidden, the key likely lacks the required scope.";
221
- case 404:
222
- return "Not found, check PYAI_BASE_URL and the route.";
223
- case 429:
224
- return "Rate/concurrency limited, back off and retry.";
225
- default:
226
- return err.message;
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
- async function doctorCheck(checks, name, fn) {
230
- try {
231
- checks.push({ name, status: "PASS", detail: await fn() });
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
- catch (err) {
234
- const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : err.message;
235
- checks.push({ name, status: "FAIL", detail, hint: remediation(err) });
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
- /** Deeper than smoke: key/scopes, endpoint liveness, and a Speak→Hear round-trip. */
239
- async function cmdDoctor(flags) {
240
- const pyai = client(flags);
241
- const checks = [];
242
- // (a) Key validity + scopes via GET /v1/me. The route is new, so a 404 means
243
- // "not deployed here yet", skip it rather than failing the whole doctor.
244
- try {
245
- const me = await pyai.me();
246
- const scopes = Array.isArray(me.scopes) ? me.scopes : [];
247
- const env = me.environment ?? me.env ?? "unknown";
248
- checks.push({
249
- name: "key (/v1/me)",
250
- status: "PASS",
251
- detail: `env=${env}; ${scopes.length} scope(s)${scopes.length ? `: ${scopes.join(", ")}` : ""}`,
252
- });
253
- }
254
- catch (err) {
255
- if (err instanceof PyAIError && err.status === 404) {
256
- checks.push({ name: "key (/v1/me)", status: "SKIP", detail: "introspection route not on this deployment" });
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
- else {
259
- const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""}`.trim() : err.message;
260
- checks.push({ name: "key (/v1/me)", status: "FAIL", detail, hint: remediation(err) });
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
- // (b) Endpoint liveness.
264
- await doctorCheck(checks, "models.list", async () => `${(await pyai.models.list()).data.length} models`);
265
- await doctorCheck(checks, "voices.list", async () => `${(await pyai.voices.list()).data.length} voices`);
266
- // (c) Speak -> Hear round-trip: synthesize a sentence, then transcribe it.
267
- await doctorCheck(checks, "speak→hear round-trip", async () => {
268
- const audio = await pyai.audio.speech({ input: "The quick brown fox jumps over the lazy dog." });
269
- const bytes = Buffer.from(audio).byteLength;
270
- const blob = new Blob([audio], { type: "audio/wav" });
271
- const tr = await pyai.audio.transcriptions.create({ file: blob, filename: "doctor.wav" });
272
- const text = (tr.text ?? "").trim();
273
- if (!text)
274
- throw new Error(`synthesized ${bytes} bytes but transcription came back empty`);
275
- return `synth ${bytes} bytes "${text.length > 60 ? `${text.slice(0, 60)}…` : text}"`;
276
- });
277
- for (const c of checks) {
278
- out(`${c.status.padEnd(4)} ${c.name}, ${c.detail}`);
279
- if (c.hint)
280
- out(` ↳ ${c.hint}`);
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
- const failed = checks.filter((c) => c.status === "FAIL");
283
- if (failed.length === 0) {
284
- out("\nDiagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.");
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
- else {
287
- out(`\nDiagnosis: ${failed.length} check(s) failed, see the remediation hints above.`);
288
- process.exit(1);
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
- async function main() {
292
- const flags = parseArgs(process.argv.slice(2));
293
- const cmd = flags._[0];
294
- switch (cmd) {
295
- case "doctor":
296
- return cmdDoctor(flags);
297
- case "smoke":
298
- return cmdSmoke(flags);
299
- case "models":
300
- return cmdModels(flags);
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
- default:
312
- fail(`unknown command: ${cmd}\n\n${USAGE}`);
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
- main().catch((err) => {
316
- if (err instanceof PyAIError) {
317
- fail(`API error ${err.status}${err.code ? ` (${err.code})` : ""}: ${err.message}`);
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
- fail(err.message);
723
+ process.exitCode = e.exitCode;
320
724
  });