@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/src/cli.ts CHANGED
@@ -1,338 +1,482 @@
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
- */
2
+ import "./cli-runtime.ts";
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.ts";
11
+ import { browserLogin } from "./cli-web-auth.ts";
12
+ import { CliConfigError, resolveConfig, saveProfile, removeProfile, useProfile, listProfiles, validateApiKey, validateProfileName } from "./cli-config.ts";
13
+ import { routes } from "./cli-routes.ts";
14
+ import { aliases, shortFlags, recipes } from "./cli-dx.ts";
15
+ import { CLI_TEMPLATES, scaffoldProject, CliInitError } from "./cli-init.ts";
16
+ import { SPEECH_FORMATS, SPEECH_SAMPLE_RATES } from "./index.ts";
18
17
 
19
- import { writeFile } from "node:fs/promises";
20
- import PyAI, { PyAIError } from "./index.ts";
18
+ type FlagType = "boolean" | "string" | "repeat";
19
+ type Flags = Record<string, string | boolean | string[]>;
20
+ type Command = { command: string; description: string; flags?: Record<string, FlagType>; args?: string[]; optionalArgs?: boolean; variadic?: boolean; example?: string };
21
+ const globals: Record<string, FlagType> = {
22
+ "api-key": "string", "base-url": "string", profile: "string", json: "boolean",
23
+ timeout: "string", retries: "string", "dry-run": "boolean", help: "boolean", version: "boolean",
24
+ };
25
+ const bodyFlags: Record<string, FlagType> = { data: "string", "idempotency-key": "string" };
26
+ const outputFlags: Record<string, FlagType> = { out: "string", force: "boolean" };
27
+ const waitFlags: Record<string, FlagType> = { "wait-timeout": "string", "poll-interval": "string" };
28
+ const commands: Command[] = [
29
+ { 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" },
30
+ { command: "auth sandbox", description: "Create a sandbox tenant and save its key in a profile" },
31
+ { command: "auth status", description: "Inspect the active key with /v1/me" },
32
+ { command: "auth logout", description: "Remove a saved local profile (does not revoke the key)" },
33
+ { command: "profiles list", description: "List saved profiles without revealing keys" },
34
+ { command: "profiles use", description: "Select the default profile", args: ["name"] },
35
+ { command: "models list", description: "List available models" },
36
+ { command: "voices list", description: "List voices", flags: { gender: "string", region: "string", language: "string", tier: "string", q: "string", source: "string" } },
37
+ { command: "voices get", description: "Get one voice", args: ["id"] },
38
+ { 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' },
39
+ { 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" },
40
+ { 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" },
41
+ { command: "clones create", description: "Clone a voice from a reference audio file", flags: { file: "string", filename: "string", name: "string" } },
42
+ { 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 } },
43
+ { 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" },
44
+ { 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" },
45
+ { command: "recipes", description: "Browse copyable workflows offline; never executes the examples", args: ["name"], optionalArgs: true, example: "pyai recipes speak" },
46
+ { 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" },
47
+ { command: "smoke", description: "Check models, voices, and speech synthesis", flags: { "tolerate-upstream": "boolean" } },
48
+ { command: "doctor", description: "Diagnose key, catalogs, and a Speak to Hear round-trip" },
49
+ ...routes.map((r): Command => ({ command: r.command, description: r.description, args: r.id ? ["id"] : [], flags: {
50
+ ...(r.body ? bodyFlags : {}), ...(r.binary ? outputFlags : {}), ...(r.wait ? waitFlags : {}),
51
+ ...(r.query ?? {}), ...(r.confirm ? { confirm: "boolean" as const } : {}),
52
+ ...(r.command.endsWith(" list") || r.command === "amd calls" ? { limit: "string" as const, cursor: "string" as const } : {}),
53
+ } })),
54
+ ];
21
55
 
22
- interface Flags {
23
- _: string[];
24
- [k: string]: string | boolean | string[];
56
+ function usage(message: string): never { throw new CliError("invalid_arguments", message, 2); }
57
+ function value(flags: Flags, name: string): string | undefined { return typeof flags[name] === "string" ? flags[name] as string : undefined; }
58
+ function required(flags: Flags, name: string): string { const v = value(flags, name); if (!v) usage(`--${name} is required`); return v; }
59
+ function number(flags: Flags, name: string, fallback: number, min = 0, max = 86400): number {
60
+ const input = value(flags, name); const n = input === undefined ? fallback : Number(input);
61
+ if (!Number.isFinite(n) || n < min || n > max || input?.trim() === "") usage(`--${name} must be a number from ${min} to ${max}`);
62
+ return n;
25
63
  }
26
-
27
- function parseArgs(argv: string[]): Flags {
28
- const flags: Flags = { _: [] };
64
+ function parse(argv: string[]): { spec?: Command; flags: Flags; args: string[] } {
65
+ const known: Record<string, FlagType> = Object.assign(Object.create(null), globals);
66
+ for (const c of commands) Object.assign(known, c.flags);
67
+ const flags: Flags = Object.create(null); const words: string[] = [];
29
68
  for (let i = 0; i < argv.length; i++) {
30
- const a = argv[i]!;
31
- if (a.startsWith("--")) {
32
- const key = a.slice(2);
33
- const next = argv[i + 1];
34
- if (next === undefined || next.startsWith("--")) {
35
- flags[key] = true;
36
- } else {
37
- flags[key] = next;
38
- i++;
39
- }
69
+ let a = argv[i]!;
70
+ if (a === "--") { words.push(...argv.slice(i + 1)); break; }
71
+ a = shortFlags[a] ?? a;
72
+ if (!a.startsWith("--")) { if (a.startsWith("-") && a !== "-") usage(`Unknown option ${a}`); words.push(a); continue; }
73
+ const equal = a.indexOf("="); const key = a.slice(2, equal < 0 ? undefined : equal); const type = known[key];
74
+ if (!type) usage(`Unknown option --${key}; run pyai --help`);
75
+ if (type === "boolean") { if (equal >= 0) usage(`--${key} does not take a value`); flags[key] = true; continue; }
76
+ const v = equal >= 0 ? a.slice(equal + 1) : argv[++i];
77
+ if (v === undefined || (equal < 0 && (v.startsWith("--") || v in shortFlags))) usage(`--${key} requires a value`);
78
+ if (type === "repeat") { const list = (flags[key] ?? []) as string[]; list.push(v); flags[key] = list; }
79
+ else { if (flags[key] !== undefined) usage(`--${key} may only be supplied once`); flags[key] = v; }
80
+ }
81
+ if (words[0] === "help") { flags.help = true; words.shift(); }
82
+ if (words[0] && aliases[words[0]]) words.splice(0, 1, ...aliases[words[0]]!.split(" "));
83
+ if (["models", "voices", "profiles"].includes(words[0] ?? "") && words.length === 1) words.push("list");
84
+ const spec = [...commands].sort((a, b) => b.command.length - a.command.length).find(c => c.command.split(" ").every((w, i) => words[i] === w));
85
+ if (!spec) {
86
+ if (words.length && !flags.help) usage(`Unknown command ${words.join(" ")}; run pyai --help`);
87
+ return { flags, args: words };
88
+ }
89
+ const args = words.slice(spec.command.split(" ").length);
90
+ const expected = spec.args?.length ?? 0;
91
+ if (!flags.help && ((!spec.optionalArgs && args.length < expected) || (!spec.variadic && args.length > expected))) usage(`Usage: pyai ${spec.command} ${(spec.args ?? []).map(x => spec.optionalArgs ? `[${x}]` : `<${x}>`).join(" ")}`);
92
+ for (const k of Object.keys(flags)) if (!(k in globals) && !(k in (spec.flags ?? {}))) usage(`--${k} is not supported by ${spec.command}`);
93
+ if (!flags.help && args.length && ["speak", "transcribe", "dub"].includes(spec.command)) {
94
+ if (spec.command === "speak") {
95
+ if (flags.text !== undefined || flags["text-file"] !== undefined) usage("Choose positional text, --text, or --text-file, not multiple inputs");
96
+ flags.text = args[0]!;
40
97
  } else {
41
- (flags._ as string[]).push(a);
98
+ if (flags.file !== undefined || flags.url !== undefined) usage("Choose a positional input, --file, or --url, not multiple inputs");
99
+ flags[/^https?:\/\//i.test(args[0]!) ? "url" : "file"] = args[0]!;
42
100
  }
43
101
  }
44
- return flags;
102
+ return { spec, flags, args };
45
103
  }
46
104
 
47
- function flag(flags: Flags, key: string): string | undefined {
48
- const v = flags[key];
49
- return typeof v === "string" ? v : undefined;
105
+ const secrets = new Set<string>();
106
+ const optionArgs = process.argv.slice(2, process.argv.indexOf("--", 2) < 0 ? undefined : process.argv.indexOf("--", 2));
107
+ const jsonMode = optionArgs.includes("--json") || optionArgs.includes("-j");
108
+ function redact(input: unknown, redactFields = true): unknown {
109
+ if (typeof input === "string") { let s = input; for (const secret of secrets) s = s.split(secret).join("[REDACTED]"); return s; }
110
+ if (Array.isArray(input)) return input.map(v => redact(v, redactFields));
111
+ if (input && typeof input === "object") return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, redactFields && /(?:api[_-]?key|authorization|password|secret|token)$/i.test(k) ? "[REDACTED]" : redact(v, redactFields)]));
112
+ return input;
50
113
  }
51
-
52
- function client(flags: Flags): PyAI {
53
- const apiKey = flag(flags, "api-key") ?? process.env.PYAI_API_KEY;
54
- if (!apiKey) {
55
- fail("No API key. Set PYAI_API_KEY or pass --api-key pyai_test_...");
114
+ function output(data: unknown, flags: Flags, human?: string, redactFields = true): void {
115
+ process.stdout.write(flags.json || !human ? `${JSON.stringify(redact(data, redactFields), null, flags.json ? 0 : 2)}\n` : `${redact(human)}\n`);
116
+ }
117
+ function transcriptOutput(data: unknown, flags: Flags, path?: string): void {
118
+ if (!flags["text-only"]) { output(data, flags); return; }
119
+ const result = data as { text?: unknown; result?: { text?: unknown }; result_url?: unknown } | null;
120
+ const text = result?.text ?? result?.result?.text;
121
+ if (typeof text !== "string") {
122
+ if (typeof result?.result_url === "string") throw new CliError("transcript_offloaded", "Transcript is stored as an external result. Use jobs get with --json to retrieve its result_url.", 1, { path });
123
+ throw new CliError("invalid_response", "The API response did not contain transcript text", 1, { path });
56
124
  }
57
- return new PyAI({ apiKey: apiKey!, baseURL: flag(flags, "base-url") ?? process.env.PYAI_BASE_URL });
125
+ process.stdout.write(`${redact(text)}${text.endsWith("\n") ? "" : "\n"}`);
58
126
  }
59
-
60
- function out(msg: string): void {
61
- process.stdout.write(`${msg}\n`);
127
+ async function stdin(): Promise<Buffer> {
128
+ if (process.stdin.isTTY) usage("Pipe input through stdin, or supply a file path");
129
+ const chunks: Buffer[] = []; let size = 0;
130
+ for await (const chunk of process.stdin) { const b = Buffer.from(chunk); size += b.length; if (size > 128 * 1024 * 1024) usage("stdin exceeds 128 MiB; use a file or hosted audio URL"); chunks.push(b); }
131
+ return Buffer.concat(chunks);
62
132
  }
63
- function fail(msg: string): never {
64
- process.stderr.write(`pyai: ${msg}\n`);
65
- process.exit(1);
133
+ async function inputFile(path: string): Promise<Buffer> { return path === "-" ? stdin() : readFile(path); }
134
+ async function jsonInput(flags: Flags, objectOnly: true): Promise<Record<string, unknown>>;
135
+ async function jsonInput(flags: Flags, objectOnly?: false): Promise<unknown>;
136
+ async function jsonInput(flags: Flags, objectOnly = false): Promise<unknown> {
137
+ const raw = required(flags, "data");
138
+ const text = raw.startsWith("@") ? (await inputFile(raw.slice(1))).toString("utf8") : raw;
139
+ let parsed: unknown; try { parsed = JSON.parse(text); } catch { usage("--data must contain valid JSON (inline, @file.json, or @-)"); }
140
+ if (objectOnly && (!parsed || typeof parsed !== "object" || Array.isArray(parsed))) usage("--data must be a JSON object");
141
+ return parsed;
66
142
  }
67
-
68
- const USAGE = `pyai, PyAI API CLI
69
-
70
- Usage:
71
- pyai doctor diagnose key/scopes + endpoint + Speak→Hear round-trip
72
- pyai smoke [--tolerate-upstream] run a key/endpoint/audio smoke test
73
- (--tolerate-upstream: transient 5xx/429 warn, don't fail)
74
- pyai models list models
75
- pyai voices [--gender g] [--region r] list voices
76
- pyai speak --text T [--voice V] [--out f.wav]
77
- pyai transcribe --url U [--diarize] [--poll]
78
-
79
- Auth: PYAI_API_KEY (or --api-key). Base: PYAI_BASE_URL (or --base-url).`;
80
-
81
- async function cmdModels(flags: Flags): Promise<void> {
82
- const pyai = client(flags);
83
- const res = await pyai.models.list();
84
- out(JSON.stringify(res, null, 2));
143
+ function headers(flags: Flags): Record<string, string> { const key = value(flags, "idempotency-key"); return key ? { "Idempotency-Key": key } : {}; }
144
+ function safeId(id: string): string { if (!id || id === "." || id === ".." || /[\x00-\x1f\x7f]/.test(id)) usage("Invalid resource id"); return encodeURIComponent(id); }
145
+ function withQuery(path: string, fields: Record<string, string | undefined>): string {
146
+ const q = new URLSearchParams(Object.entries(fields).filter((kv): kv is [string, string] => kv[1] !== undefined));
147
+ return q.toString() ? `${path}${path.includes("?") ? "&" : "?"}${q}` : path;
85
148
  }
86
-
87
- async function cmdVoices(flags: Flags): Promise<void> {
88
- const pyai = client(flags);
89
- const res = await pyai.voices.list({ gender: flag(flags, "gender"), region: flag(flags, "region") });
90
- out(JSON.stringify(res, null, 2));
149
+ function describeBody(body: unknown): unknown {
150
+ if (!(body instanceof FormData)) return body;
151
+ const fields: Record<string, unknown> = {};
152
+ body.forEach((v, k) => { fields[k] = typeof v === "string" ? v : { filename: v.name, bytes: v.size, type: v.type }; });
153
+ return fields;
91
154
  }
92
-
93
- async function cmdSpeak(flags: Flags): Promise<void> {
94
- const pyai = client(flags);
95
- const text = flag(flags, "text");
96
- if (!text) fail("speak requires --text");
97
- const audio = await pyai.audio.speech({ input: text!, voice: flag(flags, "voice") });
98
- const outPath = flag(flags, "out") ?? "pyai-speak.wav";
99
- await writeFile(outPath, Buffer.from(audio));
100
- out(`wrote ${Buffer.from(audio).byteLength} bytes -> ${outPath}`);
155
+ function preview(flags: Flags, baseURL: string, method: string, path: string, body?: unknown): boolean {
156
+ if (!flags["dry-run"]) return false;
157
+ output({ dry_run: true, method, url: `${baseURL}${path}`, body: describeBody(body) ?? null, headers: headers(flags) }, flags); return true;
101
158
  }
102
-
103
- async function cmdTranscribe(flags: Flags): Promise<void> {
104
- const pyai = client(flags);
105
- const url = flag(flags, "url");
106
- if (!url) fail("transcribe requires --url (an https audio URL)");
107
- const job = await pyai.transcriptionJobs.create({ audio_url: url!, diarize: flags.diarize === true });
108
- out(`job ${job.job_id} (${job.status})`);
109
- if (flags.poll !== true) return;
110
- for (let i = 0; i < 60; i++) {
111
- await new Promise((r) => setTimeout(r, 2000));
112
- const j = await pyai.transcriptionJobs.get(job.job_id);
113
- if (j.status === "completed" || j.status === "failed" || j.status === "cancelled") {
114
- out(JSON.stringify(j, null, 2));
115
- return;
116
- }
159
+ async function upload(flags: Flags, field: string): Promise<FormData> {
160
+ const file = required(flags, "file"); const bytes = await inputFile(file);
161
+ if (!bytes.length) usage("Audio input is empty");
162
+ const form = new FormData(); form.set(field, new Blob([new Uint8Array(bytes)]), value(flags, "filename") ?? (file === "-" ? "audio.wav" : basename(file)));
163
+ return form;
164
+ }
165
+ async function dubForm(flags: Flags, language: string, sourceLanguage?: string): Promise<FormData> {
166
+ if (!!value(flags, "file") === !!value(flags, "url")) usage("Provide exactly one audio file or URL");
167
+ if (value(flags, "file") === "-" && value(flags, "data") === "@-") usage("Audio and JSON cannot both consume stdin");
168
+ const form = value(flags, "file") ? await upload(flags, "file") : new FormData();
169
+ if (value(flags, "url")) form.set("source_url", value(flags, "url")!);
170
+ form.set("target_lang", language);
171
+ if (sourceLanguage) form.set("source_lang", sourceLanguage);
172
+ if (flags.data !== undefined) for (const [k, v] of Object.entries(await jsonInput(flags, true))) {
173
+ if (form.has(k) || ["file", "source_url"].includes(k)) usage(`Duplicate or reserved Dub field ${k}`);
174
+ form.set(k, typeof v === "string" ? v : JSON.stringify(v));
117
175
  }
118
- out(`job ${job.job_id} still running after polling; check later.`);
176
+ return form;
119
177
  }
120
-
121
- // Transient upstream conditions: a momentary engine/capacity blip or network
122
- // hiccup, NOT a key/scope/contract problem. 5xx = engine unhealthy (e.g. Speak
123
- // "503 service_unavailable"), 429 = rate/capacity, a non-PyAIError = network.
124
- // These self-heal; the others (401/403/404/400) are real and must fail loudly.
125
- const TRANSIENT_STATUSES = new Set([429, 500, 502, 503, 504]);
126
- function isTransient(err: unknown): boolean {
127
- if (err instanceof PyAIError) return TRANSIENT_STATUSES.has(err.status);
128
- return true; // network / timeout / unknown, worth a retry, never a hard fail on its own
178
+ async function checkOutput(path: string, flags: Flags): Promise<void> {
179
+ if (path === "-" && flags.json) usage("--out - cannot be combined with --json; use a file for a JSON receipt");
180
+ if (path === "-" && process.stdout.isTTY) usage("Refusing binary audio on a terminal; use --out FILE or pipe stdout");
181
+ if (path === "-") return;
182
+ const parent = await stat(dirname(resolve(path)));
183
+ if (!parent.isDirectory()) usage("Output parent must be a directory");
184
+ if (flags.force) return;
185
+ try { await lstat(path); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return; throw e; }
186
+ usage(`Output already exists: ${path}; choose another path or pass --force`);
129
187
  }
130
-
131
- /** Retry `fn` on transient upstream errors with exponential backoff. Real
132
- * (non-transient) errors throw immediately, we never paper over a bad key. */
133
- async function withRetry<T>(fn: () => Promise<T>, attempts = 4, baseMs = 800): Promise<T> {
134
- let lastErr: unknown;
135
- for (let i = 0; i < attempts; i++) {
136
- try {
137
- return await fn();
138
- } catch (err) {
139
- lastErr = err;
140
- if (!isTransient(err) || i === attempts - 1) throw err;
141
- await new Promise((r) => setTimeout(r, baseMs * 2 ** i));
188
+ const pendingAudioFiles = new Set<string>();
189
+ async function writeAudio(response: Response, path: string, flags: Flags, metadata: Record<string, unknown> = {}): Promise<void> {
190
+ if (!response.body) throw new CliError("empty_audio", "The API returned an empty audio body");
191
+ let bytes = 0;
192
+ const counter = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; callback(null, chunk); } });
193
+ const source = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
194
+ if (path === "-") { await pipeline(source, counter, process.stdout, { end: false }); return; }
195
+ const destination = resolve(path); const temporary = join(dirname(destination), `.pyai-${randomUUID()}.tmp`);
196
+ pendingAudioFiles.add(temporary);
197
+ try {
198
+ await pipeline(source, counter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
199
+ if (!bytes) throw new CliError("empty_audio", "The API returned an empty audio body");
200
+ if (flags.force) await rename(temporary, destination);
201
+ else await link(temporary, destination);
202
+ } finally { await unlink(temporary).catch(() => {}); pendingAudioFiles.delete(temporary); }
203
+ output({ ...metadata, path: destination, bytes, content_type: response.headers.get("content-type") }, flags, `wrote ${bytes} bytes -> ${path}`);
204
+ }
205
+ async function waitForJob(http: CliHttp, path: string, flags: Flags, success: string[], failure: string[]): Promise<unknown> {
206
+ const timeout = number(flags, "wait-timeout", 120, 0.001) * 1000;
207
+ const interval = number(flags, "poll-interval", 2, 0.001, 60) * 1000;
208
+ const requestTimeout = number(flags, "timeout", 30, 0.001) * 1000;
209
+ const deadline = Date.now() + timeout;
210
+ for (;;) {
211
+ const remaining = deadline - Date.now();
212
+ if (remaining <= 0) throw new CliError("job_timeout", "Job is still running; use its get or wait command to resume", 4, { path });
213
+ let job;
214
+ try { job = await http.json("GET", path, { timeoutMs: Math.min(remaining, requestTimeout) }); }
215
+ catch (e) {
216
+ if (e instanceof CliError && e.code === "timeout" && remaining <= requestTimeout) throw new CliError("job_timeout", "Job is still running; use its get or wait command to resume", 4, { path });
217
+ if (e instanceof CliError) throw new CliError(e.code, e.message, e.exitCode, { ...e.details, path });
218
+ throw e;
142
219
  }
220
+ if (!job || typeof job.status !== "string") throw new CliError("invalid_response", "Job response is missing status", 1, { path });
221
+ if (success.includes(job.status)) return job;
222
+ if (failure.includes(job.status)) throw new CliError("job_failed", "Job ended without a successful result", 1, { path, job });
223
+ await new Promise(r => setTimeout(r, Math.min(interval, Math.max(0, deadline - Date.now()))));
143
224
  }
144
- throw lastErr;
145
225
  }
146
226
 
147
- /** The headline: prove key + endpoint + audio in one command. */
148
- async function cmdSmoke(flags: Flags): Promise<void> {
149
- const pyai = client(flags);
150
- // CI/ops opt-in: a transient upstream blip (engine warming, a brief 503,
151
- // rate-limit) should not red the build, it isn't the commit's fault. With this
152
- // on, such failures are reported as WARN (exit 0); real key/scope/contract
153
- // failures still FAIL (exit 1). Off by default so a developer running `pyai
154
- // smoke` gets the strict, honest answer.
155
- const tolerateUpstream = flags["tolerate-upstream"] === true || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1";
156
- // Retry tuning is env-overridable (tests drive it fast; default rides brief blips).
157
- const retryAttempts = Number(process.env.PYAI_SMOKE_RETRY_ATTEMPTS ?? 4);
158
- const retryBaseMs = Number(process.env.PYAI_SMOKE_RETRY_BASE_MS ?? 800);
159
- type Status = "PASS" | "WARN" | "FAIL";
160
- const checks: Array<{ name: string; status: Status; detail: string }> = [];
161
- const run = async (name: string, fn: () => Promise<string>) => {
162
- try {
163
- checks.push({ name, status: "PASS", detail: await withRetry(fn, retryAttempts, retryBaseMs) });
164
- } catch (err) {
165
- const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : (err as Error).message;
166
- const status: Status = isTransient(err) && tolerateUpstream ? "WARN" : "FAIL";
167
- checks.push({ name, status, detail });
227
+ async function diagnostics(http: CliHttp, cmd: string, flags: Flags): Promise<void> {
228
+ const checks: Array<{ name: string; status: string; detail: string; hint?: string }> = [];
229
+ const tolerate = cmd === "smoke" && (flags["tolerate-upstream"] || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1");
230
+ const check = async (name: string, fn: () => Promise<string>) => {
231
+ try { checks.push({ name, status: "PASS", detail: await fn() }); }
232
+ catch (err) {
233
+ const e = err as CliError; const status = Number(e.details?.status);
234
+ if (name === "key (/v1/me)" && status === 404) { checks.push({ name, status: "SKIP", detail: "introspection route not on this deployment" }); return; }
235
+ const transient = status === 429 || status >= 500 || e.exitCode === 4;
236
+ 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.";
237
+ checks.push({ name, status: tolerate && transient ? "WARN" : "FAIL", detail: e.message, hint });
168
238
  }
169
239
  };
170
-
171
- await run("models.list", async () => {
172
- const r = await pyai.models.list();
173
- return `${r.data.length} models`;
240
+ if (cmd === "doctor") 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(", ")}`; });
241
+ await check("models.list", async () => `${(await http.json("GET", "/v1/models")).data.length} models`);
242
+ await check("voices.list", async () => `${(await http.json("GET", "/v1/voices")).data.length} voices`);
243
+ await check(cmd === "doctor" ? "speak→hear round-trip" : "audio.speech", async () => {
244
+ const response = await http.request("POST", "/v1/audio/speech", { json: { model: "pyai-speak", input: "The quick brown fox jumps over the lazy dog." } });
245
+ const audio = await response.arrayBuffer();
246
+ if (!audio.byteLength) throw new CliError("empty_audio", "Speech returned no audio");
247
+ if (cmd === "smoke") return `${audio.byteLength} bytes of audio`;
248
+ const form = new FormData(); form.set("file", new Blob([audio], { type: "audio/wav" }), "doctor.wav"); form.set("model", "pyai-hear");
249
+ const tr = await http.json("POST", "/v1/audio/transcriptions", { body: form });
250
+ if (!tr.text?.trim()) throw new CliError("empty_transcript", "Transcription came back empty");
251
+ return `synth ${audio.byteLength} bytes → ${tr.text.slice(0, 80)}`;
174
252
  });
175
- await run("voices.list", async () => {
176
- const r = await pyai.voices.list();
177
- return `${r.data.length} voices`;
178
- });
179
- await run("audio.speech", async () => {
180
- const audio = await pyai.audio.speech({ input: "PyAI smoke test." });
181
- return `${Buffer.from(audio).byteLength} bytes of audio`;
182
- });
183
-
184
- for (const c of checks) out(`${c.status} ${c.name}, ${c.detail}`);
185
- const failed = checks.filter((c) => c.status === "FAIL");
186
- const warned = checks.filter((c) => c.status === "WARN");
187
- // GitHub Actions annotation: a tolerated blip is still surfaced in the run UI.
188
- for (const c of warned) out(`::warning title=PyAI smoke transient::${c.name}: ${c.detail}`);
189
- if (failed.length === 0 && warned.length === 0) {
190
- out("\nAll checks passed. Your key, the endpoint, and audio synthesis work.");
191
- } else if (failed.length === 0) {
192
- out(`\n${warned.length} transient upstream issue(s) tolerated (self-healing engine blip), not failing the build.`);
193
- } else {
194
- out("\nSome checks failed (see above).");
195
- process.exit(1);
253
+ const failed = checks.filter(c => c.status === "FAIL").length; const warned = checks.filter(c => c.status === "WARN").length;
254
+ if (flags.json) output({ ok: !failed, checks }, flags);
255
+ else {
256
+ for (const c of checks) { process.stdout.write(`${redact(`${c.status.padEnd(4)} ${c.name}, ${c.detail}`)}\n`); if (c.hint) process.stdout.write(` ↳ ${c.hint}\n`); }
257
+ if (cmd === "doctor") process.stdout.write(failed ? `\nDiagnosis: ${failed} check(s) failed.\n` : "\nDiagnosis: healthy.\n");
258
+ else if (failed) process.stdout.write("\nSome checks failed (see above).\n");
259
+ else if (warned) { process.stdout.write(`\n${warned} transient upstream issue(s) tolerated.\n`); for (const c of checks.filter(c => c.status === "WARN")) process.stdout.write(`::warning title=PyAI smoke transient::${c.name}\n`); }
260
+ else process.stdout.write("\nAll checks passed.\n");
196
261
  }
262
+ if (failed) process.exitCode = 1;
197
263
  }
198
264
 
199
- /** Turn an error into an actionable, code-first remediation hint. */
200
- function remediation(err: unknown): string {
201
- if (!(err instanceof PyAIError)) return (err as Error)?.message ?? String(err);
202
- switch (err.code) {
203
- case "unauthorized":
204
- return "Invalid or missing key, check PYAI_API_KEY (a pyai_test_ or pyai_live_ key).";
205
- case "forbidden":
206
- return "Key is missing a required scope, add it to the key in the console.";
207
- case "origin_not_allowed":
208
- return "Publishable token origin not allow-listed, fix the allowed origins.";
209
- case "credit_exhausted":
210
- return "Out of prepaid credit, add credit, or use a pyai_test_ sandbox key.";
211
- case "key_budget_exceeded":
212
- return "Per-key monthly budget hit, raise the budget in the console.";
213
- case "insufficient_quota":
214
- return "Plan quota exhausted, upgrade your plan.";
215
- case "rate_limit_exceeded":
216
- return "Rate limited, back off and retry (honor Retry-After).";
217
- case "concurrency_limit_exceeded":
218
- return "Too many concurrent sessions, retry shortly.";
219
- case "daily_cap_exceeded":
220
- return "Daily cap reached, wait until it resets.";
221
- default:
222
- break;
265
+ async function main(): Promise<void> {
266
+ const { spec, flags, args } = parse(process.argv.slice(2));
267
+ if (flags.version) { const pkg = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")); output({ version: pkg.version }, flags, pkg.version); return; }
268
+ if (!spec || flags.help) {
269
+ const group = args.join(" ");
270
+ const grouped = group ? commands.filter(c => c.command.startsWith(`${group} `)) : commands;
271
+ const visible = spec ? [spec, ...commands.filter(c => c.command.startsWith(`${spec.command} `))] : grouped.length ? grouped : commands;
272
+ if (flags.json) output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
273
+ else if (!spec && !group) 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`);
274
+ else 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`);
275
+ return;
223
276
  }
224
- switch (err.status) {
225
- case 401:
226
- return "Invalid or missing key, check PYAI_API_KEY.";
227
- case 403:
228
- return "Forbidden, the key likely lacks the required scope.";
229
- case 404:
230
- return "Not found, check PYAI_BASE_URL and the route.";
231
- case 429:
232
- return "Rate/concurrency limited, back off and retry.";
233
- default:
234
- return err.message;
277
+ const cmd = spec.command;
278
+ if (cmd === "schema" && flags.openapi && args.length) usage("--openapi returns the complete live contract; omit the command filter");
279
+ if (cmd === "schema" && !flags.openapi) {
280
+ const filterWords = [...args];
281
+ if (filterWords[0] && aliases[filterWords[0]]) filterWords.splice(0, 1, ...aliases[filterWords[0]]!.split(" "));
282
+ const filter = filterWords.join(" ");
283
+ const matches = (c: { command: string }) => !filter || c.command === filter || c.command.startsWith(`${filter} `);
284
+ const selected = commands.filter(matches);
285
+ if (!selected.length) usage(`Unknown command group ${filter}; run pyai --help`);
286
+ 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); return;
235
287
  }
236
- }
237
-
238
- interface DoctorCheck {
239
- name: string;
240
- status: "PASS" | "FAIL" | "SKIP";
241
- detail: string;
242
- hint?: string;
243
- }
244
-
245
- async function doctorCheck(checks: DoctorCheck[], name: string, fn: () => Promise<string>): Promise<void> {
246
- try {
247
- checks.push({ name, status: "PASS", detail: await fn() });
248
- } catch (err) {
249
- const detail =
250
- err instanceof PyAIError ? `${err.status} ${err.code ?? ""} ${err.message}`.trim() : (err as Error).message;
251
- checks.push({ name, status: "FAIL", detail, hint: remediation(err) });
288
+ if (cmd === "recipes") {
289
+ const recipe = args[0] ? recipes.find(r => r.name === args[0]) : undefined;
290
+ if (args[0] && !recipe) usage(`Unknown recipe ${args[0]}; run pyai recipes`);
291
+ 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.`); return;
252
292
  }
253
- }
254
-
255
- /** Deeper than smoke: key/scopes, endpoint liveness, and a Speak→Hear round-trip. */
256
- async function cmdDoctor(flags: Flags): Promise<void> {
257
- const pyai = client(flags);
258
- const checks: DoctorCheck[] = [];
259
-
260
- // (a) Key validity + scopes via GET /v1/me. The route is new, so a 404 means
261
- // "not deployed here yet", skip it rather than failing the whole doctor.
262
- try {
263
- const me = await pyai.me();
264
- const scopes = Array.isArray(me.scopes) ? me.scopes : [];
265
- const env = me.environment ?? me.env ?? "unknown";
266
- checks.push({
267
- name: "key (/v1/me)",
268
- status: "PASS",
269
- detail: `env=${env}; ${scopes.length} scope(s)${scopes.length ? `: ${scopes.join(", ")}` : ""}`,
270
- });
271
- } catch (err) {
272
- if (err instanceof PyAIError && err.status === 404) {
273
- checks.push({ name: "key (/v1/me)", status: "SKIP", detail: "introspection route not on this deployment" });
274
- } else {
275
- const detail = err instanceof PyAIError ? `${err.status} ${err.code ?? ""}`.trim() : (err as Error).message;
276
- checks.push({ name: "key (/v1/me)", status: "FAIL", detail, hint: remediation(err) });
293
+ if (cmd === "init") {
294
+ const result = await scaffoldProject({ directory: args[0]!, template: value(flags, "template") as Parameters<typeof scaffoldProject>[0]["template"], dryRun: flags["dry-run"] === true });
295
+ 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")}`); return;
296
+ }
297
+ if (cmd === "profiles list") { output(await listProfiles(), flags); return; }
298
+ if (cmd === "profiles use") { if (!flags["dry-run"]) await useProfile(args[0]!); output({ profile: args[0], dry_run: !!flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would select" : "Selected"} profile ${args[0]}`); return; }
299
+ if (cmd === "auth login") {
300
+ const profile = validateProfileName(value(flags, "profile") ?? process.env.PYAI_PROFILE ?? "default");
301
+ if (flags["key-stdin"] && value(flags, "api-key")) usage("Choose --key-stdin or --api-key");
302
+ const explicitKey = flags["key-stdin"] || flags["api-key"] !== undefined;
303
+ if (explicitKey && (flags.web || flags["no-browser"] || flags["login-timeout"] !== undefined)) usage("Browser login options cannot be combined with --key-stdin or --api-key");
304
+ if (!explicitKey) {
305
+ const { baseURL } = await resolveConfig({ baseURL: value(flags, "base-url"), profile, allowMissingProfile: true, ignoreApiKey: true });
306
+ const timeoutMs = number(flags, "timeout", 30, 0.001) * 1000;
307
+ const loginTimeoutMs = number(flags, "login-timeout", 600, 0.001, 3600) * 1000;
308
+ if (flags["dry-run"]) { 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); return; }
309
+ const result = await browserLogin({ http: new CliHttp({ baseURL, timeoutMs, maxRetries: 0 }), baseURL,
310
+ noBrowser: flags["no-browser"] === true || (!!process.env.CI && !flags.web), timeoutMs: loginTimeoutMs, requestTimeoutMs: timeoutMs,
311
+ onSecret: key => secrets.add(key),
312
+ onAuthorization: notice => {
313
+ if (flags.json) process.stderr.write(`${JSON.stringify(notice)}\n`);
314
+ else process.stderr.write(`Open ${notice.verification_uri_complete}\nConfirm code ${notice.user_code} in the browser, then choose a project.\nWaiting for approval…\n`);
315
+ },
316
+ onBrowserUnavailable: () => {
317
+ 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");
318
+ },
319
+ });
320
+ await saveProfile(profile, { api_key: result.api_key, base_url: baseURL });
321
+ const { api_key: _key, ...metadata } = result;
322
+ output({ ...metadata, profile, base_url: baseURL, saved: true }, flags, `Signed in. Saved ${result.environment} credentials for profile ${profile}.`);
323
+ return;
277
324
  }
325
+ const key = flags["key-stdin"] ? (await stdin()).toString("utf8").trim() : required(flags, "api-key");
326
+ secrets.add(key); validateApiKey(key);
327
+ const { baseURL } = await resolveConfig({ apiKey: key, baseURL: value(flags, "base-url"), profile, allowMissingProfile: true });
328
+ if (!flags["dry-run"]) await saveProfile(profile, { api_key: key, base_url: baseURL });
329
+ output({ profile, base_url: baseURL, saved: !flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would save" : "Saved"} credentials for ${profile}`); return;
278
330
  }
279
-
280
- // (b) Endpoint liveness.
281
- await doctorCheck(checks, "models.list", async () => `${(await pyai.models.list()).data.length} models`);
282
- await doctorCheck(checks, "voices.list", async () => `${(await pyai.voices.list()).data.length} voices`);
283
-
284
- // (c) Speak -> Hear round-trip: synthesize a sentence, then transcribe it.
285
- await doctorCheck(checks, "speak→hear round-trip", async () => {
286
- const audio = await pyai.audio.speech({ input: "The quick brown fox jumps over the lazy dog." });
287
- const bytes = Buffer.from(audio).byteLength;
288
- const blob = new Blob([audio], { type: "audio/wav" });
289
- const tr = await pyai.audio.transcriptions.create({ file: blob, filename: "doctor.wav" });
290
- const text = (tr.text ?? "").trim();
291
- if (!text) throw new Error(`synthesized ${bytes} bytes but transcription came back empty`);
292
- return `synth ${bytes} bytes → "${text.length > 60 ? `${text.slice(0, 60)}…` : text}"`;
293
- });
294
-
295
- for (const c of checks) {
296
- out(`${c.status.padEnd(4)} ${c.name}, ${c.detail}`);
297
- if (c.hint) out(` ↳ ${c.hint}`);
331
+ const sandbox = cmd === "auth sandbox";
332
+ const sandboxProfile = sandbox ? validateProfileName(value(flags, "profile") ?? process.env.PYAI_PROFILE ?? "sandbox") : undefined;
333
+ const config = await resolveConfig({ apiKey: value(flags, "api-key"), baseURL: value(flags, "base-url"), profile: sandboxProfile ?? value(flags, "profile"), allowMissingProfile: sandbox });
334
+ if (config.apiKey) secrets.add(config.apiKey);
335
+ const retries = number(flags, "retries", 2, 0, 5); if (!Number.isInteger(retries)) usage("--retries must be an integer");
336
+ const http = new CliHttp({ baseURL: config.baseURL, apiKey: config.apiKey, timeoutMs: number(flags, "timeout", 30, 0.001) * 1000, maxRetries: retries });
337
+ if (cmd === "auth logout") { if (!flags["dry-run"]) await removeProfile(config.profile); output({ profile: config.profile, removed: !flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would remove" : "Removed"} saved profile ${config.profile}`); return; }
338
+ if (sandbox) {
339
+ if (preview(flags, config.baseURL, "POST", "/v1/sandbox/keys", {})) return;
340
+ const result = await http.json("POST", "/v1/sandbox/keys", { auth: false, json: {} });
341
+ if (!result || typeof result.api_key !== "string") throw new CliError("invalid_response", "Sandbox response did not contain an API key");
342
+ secrets.add(result.api_key); validateApiKey(result.api_key);
343
+ const profile = sandboxProfile!;
344
+ await saveProfile(profile, { api_key: result.api_key, base_url: config.baseURL });
345
+ const { api_key: _key, ...metadata } = result;
346
+ output({ ...metadata, profile, saved: true }, flags, `Sandbox ready. Saved credentials in profile ${profile}.`); return;
298
347
  }
299
- const failed = checks.filter((c) => c.status === "FAIL");
300
- if (failed.length === 0) {
301
- out("\nDiagnosis: healthy. Key, endpoint, and a Speak→Hear round-trip all work.");
302
- } else {
303
- out(`\nDiagnosis: ${failed.length} check(s) failed, see the remediation hints above.`);
304
- process.exit(1);
348
+ if (cmd === "smoke" || cmd === "doctor") {
349
+ if (flags["dry-run"]) { 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); return; }
350
+ await diagnostics(http, cmd, flags); return;
305
351
  }
306
- }
307
-
308
- async function main(): Promise<void> {
309
- const flags = parseArgs(process.argv.slice(2));
310
- const cmd = (flags._ as string[])[0];
311
- switch (cmd) {
312
- case "doctor":
313
- return cmdDoctor(flags);
314
- case "smoke":
315
- return cmdSmoke(flags);
316
- case "models":
317
- return cmdModels(flags);
318
- case "voices":
319
- return cmdVoices(flags);
320
- case "speak":
321
- return cmdSpeak(flags);
322
- case "transcribe":
323
- return cmdTranscribe(flags);
324
- case "help":
325
- case undefined:
326
- out(USAGE);
327
- return;
328
- default:
329
- fail(`unknown command: ${cmd}\n\n${USAGE}`);
352
+ if (cmd === "speak") {
353
+ if (flags.text === undefined && flags["text-file"] === undefined && !process.stdin.isTTY) flags["text-file"] = "-";
354
+ if (!!value(flags, "text") === !!value(flags, "text-file")) usage("Provide exactly one of --text or --text-file (use - for stdin)");
355
+ const text = value(flags, "text") ?? (await inputFile(required(flags, "text-file"))).toString("utf8");
356
+ if (!text.trim()) usage("Speech text is empty");
357
+ const format = value(flags, "format") ?? "wav";
358
+ if (!(SPEECH_FORMATS as readonly string[]).includes(format)) usage(`--format must be one of ${SPEECH_FORMATS.join(", ")}`);
359
+ const sampleRate = value(flags, "sample-rate") === undefined ? undefined : number(flags, "sample-rate", 24000, 8000, 48000);
360
+ if (sampleRate !== undefined && !(SPEECH_SAMPLE_RATES as readonly number[]).includes(sampleRate)) usage(`--sample-rate must be one of ${SPEECH_SAMPLE_RATES.join(", ")}`);
361
+ if (format.startsWith("g711_") && sampleRate !== undefined && sampleRate !== 8000) usage("G.711 audio requires --sample-rate 8000");
362
+ const payload = { input: text, model: value(flags, "model") ?? "pyai-speak", voice: value(flags, "voice"), response_format: format, sample_rate: sampleRate };
363
+ const path = value(flags, "out") ?? `pyai-speak.${format.startsWith("g711_") ? "raw" : format}`;
364
+ await checkOutput(path, flags);
365
+ if (preview(flags, config.baseURL, "POST", "/v1/audio/speech", payload)) return;
366
+ await writeAudio(await http.request("POST", "/v1/audio/speech", { json: payload }), path, flags); return;
367
+ }
368
+ if (cmd === "transcribe") {
369
+ if (flags["text-only"] && flags.json) usage("--text-only cannot be combined with --json");
370
+ if (!!value(flags, "file") === !!value(flags, "url")) usage("Provide exactly one of --file or --url");
371
+ const isJob = !!value(flags, "url");
372
+ if (flags["text-only"] && isJob && !flags.wait && !flags.poll) usage("--text-only with a URL requires --wait");
373
+ if (!isJob && (flags.diarize || flags.wait || flags.poll || flags["idempotency-key"])) usage("--diarize, --wait, --poll and --idempotency-key require --url (async jobs)");
374
+ const path = isJob ? "/v1/transcription/jobs" : "/v1/audio/transcriptions";
375
+ const body = isJob ? { audio_url: value(flags, "url"), diarize: flags.diarize === true, ...(value(flags, "language") ? { language: value(flags, "language") } : {}) } : await upload(flags, "file");
376
+ if (body instanceof FormData) { body.set("model", "pyai-hear"); if (value(flags, "language")) body.set("language", value(flags, "language")!); }
377
+ if (flags.wait || flags.poll) { number(flags, "wait-timeout", 120, 0.001); number(flags, "poll-interval", 2, 0.001, 60); }
378
+ if (preview(flags, config.baseURL, "POST", path, body)) return;
379
+ const result = await http.json("POST", path, { ...(body instanceof FormData ? { body } : { json: body }), headers: headers(flags) });
380
+ if (isJob && (flags.wait || flags.poll)) {
381
+ if (!result || typeof result.job_id !== "string") throw new CliError("invalid_response", "Job response is missing job_id");
382
+ const completed = await waitForJob(http, `${path}/${safeId(result.job_id)}`, flags, ["completed"], ["failed", "cancelled"]);
383
+ transcriptOutput(completed, flags, `${path}/${safeId(result.job_id)}`);
384
+ } else transcriptOutput(result, flags);
385
+ return;
386
+ }
387
+ if (cmd === "dub") {
388
+ const language = required(flags, "to");
389
+ const out = value(flags, "out") ?? "pyai-dub.wav";
390
+ await checkOutput(out, flags);
391
+ number(flags, "wait-timeout", 120, 0.001); number(flags, "poll-interval", 2, 0.001, 60);
392
+ const body = await dubForm(flags, language, value(flags, "from"));
393
+ if (flags["dry-run"]) {
394
+ 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); return;
395
+ }
396
+ const created = await http.json("POST", "/v1/dub", { body, headers: headers(flags) });
397
+ if (!created || typeof created.job_id !== "string" || !created.job_id) throw new CliError("invalid_response", "Dub response is missing job_id");
398
+ const path = `/v1/dub/jobs/${safeId(created.job_id)}`;
399
+ if (!flags.json) process.stderr.write(`Dub job ${redact(created.job_id)} submitted. Waiting for audio…\n`);
400
+ try {
401
+ await waitForJob(http, path, flags, ["done"], ["error"]);
402
+ await writeAudio(await http.request("GET", `${path}/audio`), out, flags, { job_id: created.job_id, status: "done" });
403
+ } catch (error) {
404
+ if (error instanceof CliError) throw new CliError(error.code, error.message, error.exitCode, { ...error.details, job_id: created.job_id, path });
405
+ throw new CliError("local_error", (error as Error).message, 2, { job_id: created.job_id, path });
406
+ }
407
+ return;
330
408
  }
409
+ if (cmd === "clones create" || cmd === "dub create") {
410
+ let form: FormData;
411
+ if (cmd === "clones create") { const name = required(flags, "name"); form = await upload(flags, "file"); form.set("name", name); }
412
+ else form = await dubForm(flags, required(flags, "language"), value(flags, "source-language"));
413
+ const path = cmd === "clones create" ? "/v1/voice/clones" : "/v1/dub";
414
+ if (preview(flags, config.baseURL, "POST", path, form)) return;
415
+ output(await http.json("POST", path, { body: form, headers: headers(flags) }), flags); return;
416
+ }
417
+ if (cmd === "request") {
418
+ const method = args[0]!.toUpperCase(); if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].includes(method)) usage("Unsupported HTTP method");
419
+ let path = args[1]!;
420
+ if (!/^\/(?!\/)/.test(path) || path.includes("\\") || /[\x00-\x20\x7f]/.test(path)) usage("Use a relative API path such as /v1/models");
421
+ const q = new URLSearchParams();
422
+ for (const item of (flags.query ?? []) as string[]) { const i = item.indexOf("="); if (i < 1) usage("--query requires name=value"); q.append(item.slice(0, i), item.slice(i + 1)); }
423
+ if (q.toString()) path += `${path.includes("?") ? "&" : "?"}${q}`;
424
+ http.validatePath(path);
425
+ const data = flags.data !== undefined ? await jsonInput(flags) : undefined;
426
+ if (data !== undefined && ["GET", "HEAD"].includes(method)) usage(`${method} does not accept --data`);
427
+ const out = value(flags, "out"); if (out) await checkOutput(out, flags);
428
+ if (preview(flags, config.baseURL, method, path, data)) return;
429
+ if (out) await writeAudio(await http.request(method, path, { json: data, headers: headers(flags) }), out, flags);
430
+ else output(await http.json(method, path, { json: data, headers: headers(flags) }), flags);
431
+ return;
432
+ }
433
+ const simplePaths: Record<string, string> = { "auth status": "/v1/me", "models list": "/v1/models", "voices list": "/v1/voices", "voices get": `/v1/voices/${args[0] ? safeId(args[0]) : ""}`, schema: "/openapi.json" };
434
+ if (cmd in simplePaths) {
435
+ const path = withQuery(simplePaths[cmd]!, Object.fromEntries(["gender", "region", "language", "tier", "q", "source"].map(k => [k, value(flags, k)])));
436
+ if (preview(flags, config.baseURL, "GET", path)) return;
437
+ output(await http.json("GET", path, { auth: cmd !== "schema" }), flags, undefined, cmd !== "schema"); return;
438
+ }
439
+ const route = routes.find(r => r.command === cmd)!;
440
+ if (route.confirm && !flags.confirm && !flags["dry-run"]) {
441
+ 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" });
442
+ }
443
+ if (route.idempotencyRequired) {
444
+ const key = required(flags, "idempotency-key");
445
+ if (!key.trim() || key.length > 255 || /[\r\n]/.test(key)) usage("--idempotency-key must be a non-empty header-safe string of at most 255 characters");
446
+ }
447
+ let path = route.path.replace("{id}", route.id ? safeId(args[0]!) : "");
448
+ if (value(flags, "limit") !== undefined) { const limit = number(flags, "limit", 20, 1, 100); if (!Number.isInteger(limit)) usage("--limit must be an integer"); }
449
+ 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])])) });
450
+ const data = route.body ? await jsonInput(flags, true) : undefined;
451
+ const out = route.binary ? required(flags, "out") : undefined;
452
+ if (out) await checkOutput(out, flags);
453
+ if (preview(flags, config.baseURL, route.method, path, data)) return;
454
+ if (route.wait) output(await waitForJob(http, path, flags, route.wait.success, route.wait.failure), flags);
455
+ else if (out) await writeAudio(await http.request(route.method, path, { json: data, headers: headers(flags) }), out, flags);
456
+ else output(await http.json(route.method, path, { json: data, headers: headers(flags) }), flags);
331
457
  }
332
458
 
333
- main().catch((err) => {
334
- if (err instanceof PyAIError) {
335
- fail(`API error ${err.status}${err.code ? ` (${err.code})` : ""}: ${err.message}`);
459
+ for (const stream of [process.stdout, process.stderr]) stream.on("error", err => { if ((err as NodeJS.ErrnoException).code === "EPIPE") process.exit(0); throw err; });
460
+ process.once("SIGINT", () => {
461
+ for (const path of pendingAudioFiles) { try { unlinkSync(path); } catch { /* Best effort cleanup on interruption. */ } }
462
+ process.stderr.write(jsonMode ? `${JSON.stringify({ error: { code: "interrupted", message: "Interrupted" } })}\n` : "pyai: interrupted\n");
463
+ process.exit(130);
464
+ });
465
+ if (process.env.PYAI_API_KEY) secrets.add(process.env.PYAI_API_KEY);
466
+ // Register explicit keys before parsing so even invalid-command errors cannot echo them.
467
+ for (let i = 2; i < process.argv.length; i++) {
468
+ const a = process.argv[i]!;
469
+ const key = a.startsWith("--api-key=") ? a.slice(10) : a === "--api-key" ? process.argv[i + 1] : undefined;
470
+ if (key) secrets.add(key);
471
+ }
472
+ main().catch(err => {
473
+ const e = err instanceof CliError ? err : err instanceof CliConfigError || err instanceof CliInitError ? new CliError(err.code, err.message, 2) : new CliError("local_error", (err as Error).message, 2);
474
+ const detail = { code: e.code, message: e.message, ...e.details };
475
+ if (jsonMode) process.stderr.write(`${JSON.stringify({ error: redact(detail) })}\n`);
476
+ else {
477
+ process.stderr.write(`pyai: ${redact(e.message)} (${redact(e.code)})\n`);
478
+ if (typeof e.details?.path === "string") process.stderr.write(`Reference: ${redact(e.details.path)}\n`);
479
+ if (typeof e.details?.job_id === "string") process.stderr.write(`Job ID: ${redact(e.details.job_id)}\n`);
336
480
  }
337
- fail((err as Error).message);
481
+ process.exitCode = e.exitCode;
338
482
  });