@pyai/sdk 0.3.1 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_GUIDE.md +296 -0
- package/CLI.md +609 -0
- package/CLI.schema.json +1085 -0
- package/README.md +49 -22
- package/dist/cli-config.d.ts +47 -0
- package/dist/cli-config.js +265 -0
- package/dist/cli-dx.d.ts +11 -0
- package/dist/cli-dx.js +32 -0
- package/dist/cli-http.d.ts +38 -0
- package/dist/cli-http.js +286 -0
- package/dist/cli-init.d.ts +27 -0
- package/dist/cli-init.js +430 -0
- package/dist/cli-routes.d.ts +15 -0
- package/dist/cli-routes.js +52 -0
- package/dist/cli-runtime.d.ts +10 -0
- package/dist/cli-runtime.js +23 -0
- package/dist/cli-web-auth.d.ts +33 -0
- package/dist/cli-web-auth.js +154 -0
- package/dist/cli.d.ts +1 -17
- package/dist/cli.js +674 -270
- package/dist/index.d.ts +206 -26
- package/dist/index.js +98 -12
- package/package.json +7 -2
- package/src/cli-config.ts +283 -0
- package/src/cli-dx.ts +33 -0
- package/src/cli-http.ts +273 -0
- package/src/cli-init.ts +430 -0
- package/src/cli-routes.ts +72 -0
- package/src/cli-runtime.ts +30 -0
- package/src/cli-web-auth.ts +148 -0
- package/src/cli.ts +431 -295
- package/src/index.ts +259 -32
package/src/cli.ts
CHANGED
|
@@ -1,338 +1,474 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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.command.endsWith(" list") || r.command === "amd calls" ? { limit: "string" as const, cursor: "string" as const } : {}),
|
|
52
|
+
} })),
|
|
53
|
+
];
|
|
21
54
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
55
|
+
function usage(message: string): never { throw new CliError("invalid_arguments", message, 2); }
|
|
56
|
+
function value(flags: Flags, name: string): string | undefined { return typeof flags[name] === "string" ? flags[name] as string : undefined; }
|
|
57
|
+
function required(flags: Flags, name: string): string { const v = value(flags, name); if (!v) usage(`--${name} is required`); return v; }
|
|
58
|
+
function number(flags: Flags, name: string, fallback: number, min = 0, max = 86400): number {
|
|
59
|
+
const input = value(flags, name); const n = input === undefined ? fallback : Number(input);
|
|
60
|
+
if (!Number.isFinite(n) || n < min || n > max || input?.trim() === "") usage(`--${name} must be a number from ${min} to ${max}`);
|
|
61
|
+
return n;
|
|
25
62
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const
|
|
63
|
+
function parse(argv: string[]): { spec?: Command; flags: Flags; args: string[] } {
|
|
64
|
+
const known: Record<string, FlagType> = Object.assign(Object.create(null), globals);
|
|
65
|
+
for (const c of commands) Object.assign(known, c.flags);
|
|
66
|
+
const flags: Flags = Object.create(null); const words: string[] = [];
|
|
29
67
|
for (let i = 0; i < argv.length; i++) {
|
|
30
|
-
|
|
31
|
-
if (a
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
68
|
+
let a = argv[i]!;
|
|
69
|
+
if (a === "--") { words.push(...argv.slice(i + 1)); break; }
|
|
70
|
+
a = shortFlags[a] ?? a;
|
|
71
|
+
if (!a.startsWith("--")) { if (a.startsWith("-") && a !== "-") usage(`Unknown option ${a}`); words.push(a); continue; }
|
|
72
|
+
const equal = a.indexOf("="); const key = a.slice(2, equal < 0 ? undefined : equal); const type = known[key];
|
|
73
|
+
if (!type) usage(`Unknown option --${key}; run pyai --help`);
|
|
74
|
+
if (type === "boolean") { if (equal >= 0) usage(`--${key} does not take a value`); flags[key] = true; continue; }
|
|
75
|
+
const v = equal >= 0 ? a.slice(equal + 1) : argv[++i];
|
|
76
|
+
if (v === undefined || (equal < 0 && (v.startsWith("--") || v in shortFlags))) usage(`--${key} requires a value`);
|
|
77
|
+
if (type === "repeat") { const list = (flags[key] ?? []) as string[]; list.push(v); flags[key] = list; }
|
|
78
|
+
else { if (flags[key] !== undefined) usage(`--${key} may only be supplied once`); flags[key] = v; }
|
|
79
|
+
}
|
|
80
|
+
if (words[0] === "help") { flags.help = true; words.shift(); }
|
|
81
|
+
if (words[0] && aliases[words[0]]) words.splice(0, 1, ...aliases[words[0]]!.split(" "));
|
|
82
|
+
if (["models", "voices", "profiles"].includes(words[0] ?? "") && words.length === 1) words.push("list");
|
|
83
|
+
const spec = [...commands].sort((a, b) => b.command.length - a.command.length).find(c => c.command.split(" ").every((w, i) => words[i] === w));
|
|
84
|
+
if (!spec) {
|
|
85
|
+
if (words.length && !flags.help) usage(`Unknown command ${words.join(" ")}; run pyai --help`);
|
|
86
|
+
return { flags, args: words };
|
|
87
|
+
}
|
|
88
|
+
const args = words.slice(spec.command.split(" ").length);
|
|
89
|
+
const expected = spec.args?.length ?? 0;
|
|
90
|
+
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(" ")}`);
|
|
91
|
+
for (const k of Object.keys(flags)) if (!(k in globals) && !(k in (spec.flags ?? {}))) usage(`--${k} is not supported by ${spec.command}`);
|
|
92
|
+
if (!flags.help && args.length && ["speak", "transcribe", "dub"].includes(spec.command)) {
|
|
93
|
+
if (spec.command === "speak") {
|
|
94
|
+
if (flags.text !== undefined || flags["text-file"] !== undefined) usage("Choose positional text, --text, or --text-file, not multiple inputs");
|
|
95
|
+
flags.text = args[0]!;
|
|
40
96
|
} else {
|
|
41
|
-
(flags.
|
|
97
|
+
if (flags.file !== undefined || flags.url !== undefined) usage("Choose a positional input, --file, or --url, not multiple inputs");
|
|
98
|
+
flags[/^https?:\/\//i.test(args[0]!) ? "url" : "file"] = args[0]!;
|
|
42
99
|
}
|
|
43
100
|
}
|
|
44
|
-
return flags;
|
|
101
|
+
return { spec, flags, args };
|
|
45
102
|
}
|
|
46
103
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
104
|
+
const secrets = new Set<string>();
|
|
105
|
+
const optionArgs = process.argv.slice(2, process.argv.indexOf("--", 2) < 0 ? undefined : process.argv.indexOf("--", 2));
|
|
106
|
+
const jsonMode = optionArgs.includes("--json") || optionArgs.includes("-j");
|
|
107
|
+
function redact(input: unknown, redactFields = true): unknown {
|
|
108
|
+
if (typeof input === "string") { let s = input; for (const secret of secrets) s = s.split(secret).join("[REDACTED]"); return s; }
|
|
109
|
+
if (Array.isArray(input)) return input.map(v => redact(v, redactFields));
|
|
110
|
+
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)]));
|
|
111
|
+
return input;
|
|
50
112
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
113
|
+
function output(data: unknown, flags: Flags, human?: string, redactFields = true): void {
|
|
114
|
+
process.stdout.write(flags.json || !human ? `${JSON.stringify(redact(data, redactFields), null, flags.json ? 0 : 2)}\n` : `${redact(human)}\n`);
|
|
115
|
+
}
|
|
116
|
+
function transcriptOutput(data: unknown, flags: Flags, path?: string): void {
|
|
117
|
+
if (!flags["text-only"]) { output(data, flags); return; }
|
|
118
|
+
const result = data as { text?: unknown; result?: { text?: unknown }; result_url?: unknown } | null;
|
|
119
|
+
const text = result?.text ?? result?.result?.text;
|
|
120
|
+
if (typeof text !== "string") {
|
|
121
|
+
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 });
|
|
122
|
+
throw new CliError("invalid_response", "The API response did not contain transcript text", 1, { path });
|
|
56
123
|
}
|
|
57
|
-
|
|
124
|
+
process.stdout.write(`${redact(text)}${text.endsWith("\n") ? "" : "\n"}`);
|
|
58
125
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
126
|
+
async function stdin(): Promise<Buffer> {
|
|
127
|
+
if (process.stdin.isTTY) usage("Pipe input through stdin, or supply a file path");
|
|
128
|
+
const chunks: Buffer[] = []; let size = 0;
|
|
129
|
+
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); }
|
|
130
|
+
return Buffer.concat(chunks);
|
|
62
131
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
132
|
+
async function inputFile(path: string): Promise<Buffer> { return path === "-" ? stdin() : readFile(path); }
|
|
133
|
+
async function jsonInput(flags: Flags, objectOnly: true): Promise<Record<string, unknown>>;
|
|
134
|
+
async function jsonInput(flags: Flags, objectOnly?: false): Promise<unknown>;
|
|
135
|
+
async function jsonInput(flags: Flags, objectOnly = false): Promise<unknown> {
|
|
136
|
+
const raw = required(flags, "data");
|
|
137
|
+
const text = raw.startsWith("@") ? (await inputFile(raw.slice(1))).toString("utf8") : raw;
|
|
138
|
+
let parsed: unknown; try { parsed = JSON.parse(text); } catch { usage("--data must contain valid JSON (inline, @file.json, or @-)"); }
|
|
139
|
+
if (objectOnly && (!parsed || typeof parsed !== "object" || Array.isArray(parsed))) usage("--data must be a JSON object");
|
|
140
|
+
return parsed;
|
|
66
141
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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));
|
|
142
|
+
function headers(flags: Flags): Record<string, string> { const key = value(flags, "idempotency-key"); return key ? { "Idempotency-Key": key } : {}; }
|
|
143
|
+
function safeId(id: string): string { if (!id || id === "." || id === ".." || /[\x00-\x1f\x7f]/.test(id)) usage("Invalid resource id"); return encodeURIComponent(id); }
|
|
144
|
+
function withQuery(path: string, fields: Record<string, string | undefined>): string {
|
|
145
|
+
const q = new URLSearchParams(Object.entries(fields).filter((kv): kv is [string, string] => kv[1] !== undefined));
|
|
146
|
+
return q.toString() ? `${path}${path.includes("?") ? "&" : "?"}${q}` : path;
|
|
85
147
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
148
|
+
function describeBody(body: unknown): unknown {
|
|
149
|
+
if (!(body instanceof FormData)) return body;
|
|
150
|
+
const fields: Record<string, unknown> = {};
|
|
151
|
+
body.forEach((v, k) => { fields[k] = typeof v === "string" ? v : { filename: v.name, bytes: v.size, type: v.type }; });
|
|
152
|
+
return fields;
|
|
91
153
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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}`);
|
|
154
|
+
function preview(flags: Flags, baseURL: string, method: string, path: string, body?: unknown): boolean {
|
|
155
|
+
if (!flags["dry-run"]) return false;
|
|
156
|
+
output({ dry_run: true, method, url: `${baseURL}${path}`, body: describeBody(body) ?? null, headers: headers(flags) }, flags); return true;
|
|
101
157
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
if (flags
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
158
|
+
async function upload(flags: Flags, field: string): Promise<FormData> {
|
|
159
|
+
const file = required(flags, "file"); const bytes = await inputFile(file);
|
|
160
|
+
if (!bytes.length) usage("Audio input is empty");
|
|
161
|
+
const form = new FormData(); form.set(field, new Blob([new Uint8Array(bytes)]), value(flags, "filename") ?? (file === "-" ? "audio.wav" : basename(file)));
|
|
162
|
+
return form;
|
|
163
|
+
}
|
|
164
|
+
async function dubForm(flags: Flags, language: string, sourceLanguage?: string): Promise<FormData> {
|
|
165
|
+
if (!!value(flags, "file") === !!value(flags, "url")) usage("Provide exactly one audio file or URL");
|
|
166
|
+
if (value(flags, "file") === "-" && value(flags, "data") === "@-") usage("Audio and JSON cannot both consume stdin");
|
|
167
|
+
const form = value(flags, "file") ? await upload(flags, "file") : new FormData();
|
|
168
|
+
if (value(flags, "url")) form.set("source_url", value(flags, "url")!);
|
|
169
|
+
form.set("target_lang", language);
|
|
170
|
+
if (sourceLanguage) form.set("source_lang", sourceLanguage);
|
|
171
|
+
if (flags.data !== undefined) for (const [k, v] of Object.entries(await jsonInput(flags, true))) {
|
|
172
|
+
if (form.has(k) || ["file", "source_url"].includes(k)) usage(`Duplicate or reserved Dub field ${k}`);
|
|
173
|
+
form.set(k, typeof v === "string" ? v : JSON.stringify(v));
|
|
117
174
|
}
|
|
118
|
-
|
|
175
|
+
return form;
|
|
119
176
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
177
|
+
async function checkOutput(path: string, flags: Flags): Promise<void> {
|
|
178
|
+
if (path === "-" && flags.json) usage("--out - cannot be combined with --json; use a file for a JSON receipt");
|
|
179
|
+
if (path === "-" && process.stdout.isTTY) usage("Refusing binary audio on a terminal; use --out FILE or pipe stdout");
|
|
180
|
+
if (path === "-") return;
|
|
181
|
+
const parent = await stat(dirname(resolve(path)));
|
|
182
|
+
if (!parent.isDirectory()) usage("Output parent must be a directory");
|
|
183
|
+
if (flags.force) return;
|
|
184
|
+
try { await lstat(path); } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") return; throw e; }
|
|
185
|
+
usage(`Output already exists: ${path}; choose another path or pass --force`);
|
|
129
186
|
}
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
187
|
+
const pendingAudioFiles = new Set<string>();
|
|
188
|
+
async function writeAudio(response: Response, path: string, flags: Flags, metadata: Record<string, unknown> = {}): Promise<void> {
|
|
189
|
+
if (!response.body) throw new CliError("empty_audio", "The API returned an empty audio body");
|
|
190
|
+
let bytes = 0;
|
|
191
|
+
const counter = new Transform({ transform(chunk, _encoding, callback) { bytes += chunk.length; callback(null, chunk); } });
|
|
192
|
+
const source = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
|
|
193
|
+
if (path === "-") { await pipeline(source, counter, process.stdout, { end: false }); return; }
|
|
194
|
+
const destination = resolve(path); const temporary = join(dirname(destination), `.pyai-${randomUUID()}.tmp`);
|
|
195
|
+
pendingAudioFiles.add(temporary);
|
|
196
|
+
try {
|
|
197
|
+
await pipeline(source, counter, createWriteStream(temporary, { flags: "wx", mode: 0o600 }));
|
|
198
|
+
if (!bytes) throw new CliError("empty_audio", "The API returned an empty audio body");
|
|
199
|
+
if (flags.force) await rename(temporary, destination);
|
|
200
|
+
else await link(temporary, destination);
|
|
201
|
+
} finally { await unlink(temporary).catch(() => {}); pendingAudioFiles.delete(temporary); }
|
|
202
|
+
output({ ...metadata, path: destination, bytes, content_type: response.headers.get("content-type") }, flags, `wrote ${bytes} bytes -> ${path}`);
|
|
203
|
+
}
|
|
204
|
+
async function waitForJob(http: CliHttp, path: string, flags: Flags, success: string[], failure: string[]): Promise<unknown> {
|
|
205
|
+
const timeout = number(flags, "wait-timeout", 120, 0.001) * 1000;
|
|
206
|
+
const interval = number(flags, "poll-interval", 2, 0.001, 60) * 1000;
|
|
207
|
+
const requestTimeout = number(flags, "timeout", 30, 0.001) * 1000;
|
|
208
|
+
const deadline = Date.now() + timeout;
|
|
209
|
+
for (;;) {
|
|
210
|
+
const remaining = deadline - Date.now();
|
|
211
|
+
if (remaining <= 0) throw new CliError("job_timeout", "Job is still running; use its get or wait command to resume", 4, { path });
|
|
212
|
+
let job;
|
|
213
|
+
try { job = await http.json("GET", path, { timeoutMs: Math.min(remaining, requestTimeout) }); }
|
|
214
|
+
catch (e) {
|
|
215
|
+
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 });
|
|
216
|
+
if (e instanceof CliError) throw new CliError(e.code, e.message, e.exitCode, { ...e.details, path });
|
|
217
|
+
throw e;
|
|
142
218
|
}
|
|
219
|
+
if (!job || typeof job.status !== "string") throw new CliError("invalid_response", "Job response is missing status", 1, { path });
|
|
220
|
+
if (success.includes(job.status)) return job;
|
|
221
|
+
if (failure.includes(job.status)) throw new CliError("job_failed", "Job ended without a successful result", 1, { path, job });
|
|
222
|
+
await new Promise(r => setTimeout(r, Math.min(interval, Math.max(0, deadline - Date.now()))));
|
|
143
223
|
}
|
|
144
|
-
throw lastErr;
|
|
145
224
|
}
|
|
146
225
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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 });
|
|
226
|
+
async function diagnostics(http: CliHttp, cmd: string, flags: Flags): Promise<void> {
|
|
227
|
+
const checks: Array<{ name: string; status: string; detail: string; hint?: string }> = [];
|
|
228
|
+
const tolerate = cmd === "smoke" && (flags["tolerate-upstream"] || process.env.PYAI_SMOKE_TOLERATE_UPSTREAM === "1");
|
|
229
|
+
const check = async (name: string, fn: () => Promise<string>) => {
|
|
230
|
+
try { checks.push({ name, status: "PASS", detail: await fn() }); }
|
|
231
|
+
catch (err) {
|
|
232
|
+
const e = err as CliError; const status = Number(e.details?.status);
|
|
233
|
+
if (name === "key (/v1/me)" && status === 404) { checks.push({ name, status: "SKIP", detail: "introspection route not on this deployment" }); return; }
|
|
234
|
+
const transient = status === 429 || status >= 500 || e.exitCode === 4;
|
|
235
|
+
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.";
|
|
236
|
+
checks.push({ name, status: tolerate && transient ? "WARN" : "FAIL", detail: e.message, hint });
|
|
168
237
|
}
|
|
169
238
|
};
|
|
170
|
-
|
|
171
|
-
await
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
return `${
|
|
239
|
+
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(", ")}`; });
|
|
240
|
+
await check("models.list", async () => `${(await http.json("GET", "/v1/models")).data.length} models`);
|
|
241
|
+
await check("voices.list", async () => `${(await http.json("GET", "/v1/voices")).data.length} voices`);
|
|
242
|
+
await check(cmd === "doctor" ? "speak→hear round-trip" : "audio.speech", async () => {
|
|
243
|
+
const response = await http.request("POST", "/v1/audio/speech", { json: { model: "pyai-speak", input: "The quick brown fox jumps over the lazy dog." } });
|
|
244
|
+
const audio = await response.arrayBuffer();
|
|
245
|
+
if (!audio.byteLength) throw new CliError("empty_audio", "Speech returned no audio");
|
|
246
|
+
if (cmd === "smoke") return `${audio.byteLength} bytes of audio`;
|
|
247
|
+
const form = new FormData(); form.set("file", new Blob([audio], { type: "audio/wav" }), "doctor.wav"); form.set("model", "pyai-hear");
|
|
248
|
+
const tr = await http.json("POST", "/v1/audio/transcriptions", { body: form });
|
|
249
|
+
if (!tr.text?.trim()) throw new CliError("empty_transcript", "Transcription came back empty");
|
|
250
|
+
return `synth ${audio.byteLength} bytes → ${tr.text.slice(0, 80)}`;
|
|
178
251
|
});
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
252
|
+
const failed = checks.filter(c => c.status === "FAIL").length; const warned = checks.filter(c => c.status === "WARN").length;
|
|
253
|
+
if (flags.json) output({ ok: !failed, checks }, flags);
|
|
254
|
+
else {
|
|
255
|
+
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`); }
|
|
256
|
+
if (cmd === "doctor") process.stdout.write(failed ? `\nDiagnosis: ${failed} check(s) failed.\n` : "\nDiagnosis: healthy.\n");
|
|
257
|
+
else if (failed) process.stdout.write("\nSome checks failed (see above).\n");
|
|
258
|
+
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`); }
|
|
259
|
+
else process.stdout.write("\nAll checks passed.\n");
|
|
196
260
|
}
|
|
261
|
+
if (failed) process.exitCode = 1;
|
|
197
262
|
}
|
|
198
263
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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;
|
|
264
|
+
async function main(): Promise<void> {
|
|
265
|
+
const { spec, flags, args } = parse(process.argv.slice(2));
|
|
266
|
+
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; }
|
|
267
|
+
if (!spec || flags.help) {
|
|
268
|
+
const group = args.join(" ");
|
|
269
|
+
const grouped = group ? commands.filter(c => c.command.startsWith(`${group} `)) : commands;
|
|
270
|
+
const visible = spec ? [spec, ...commands.filter(c => c.command.startsWith(`${spec.command} `))] : grouped.length ? grouped : commands;
|
|
271
|
+
if (flags.json) output({ name: "pyai", globals, aliases, short_flags: shortFlags, commands: visible }, flags, undefined, false);
|
|
272
|
+
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\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`);
|
|
273
|
+
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`);
|
|
274
|
+
return;
|
|
223
275
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return err.message;
|
|
276
|
+
const cmd = spec.command;
|
|
277
|
+
if (cmd === "schema" && flags.openapi && args.length) usage("--openapi returns the complete live contract; omit the command filter");
|
|
278
|
+
if (cmd === "schema" && !flags.openapi) {
|
|
279
|
+
const filterWords = [...args];
|
|
280
|
+
if (filterWords[0] && aliases[filterWords[0]]) filterWords.splice(0, 1, ...aliases[filterWords[0]]!.split(" "));
|
|
281
|
+
const filter = filterWords.join(" ");
|
|
282
|
+
const matches = (c: { command: string }) => !filter || c.command === filter || c.command.startsWith(`${filter} `);
|
|
283
|
+
const selected = commands.filter(matches);
|
|
284
|
+
if (!selected.length) usage(`Unknown command group ${filter}; run pyai --help`);
|
|
285
|
+
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
286
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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) });
|
|
287
|
+
if (cmd === "recipes") {
|
|
288
|
+
const recipe = args[0] ? recipes.find(r => r.name === args[0]) : undefined;
|
|
289
|
+
if (args[0] && !recipe) usage(`Unknown recipe ${args[0]}; run pyai recipes`);
|
|
290
|
+
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
291
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
292
|
+
if (cmd === "init") {
|
|
293
|
+
const result = await scaffoldProject({ directory: args[0]!, template: value(flags, "template") as Parameters<typeof scaffoldProject>[0]["template"], dryRun: flags["dry-run"] === true });
|
|
294
|
+
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;
|
|
295
|
+
}
|
|
296
|
+
if (cmd === "profiles list") { output(await listProfiles(), flags); return; }
|
|
297
|
+
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; }
|
|
298
|
+
if (cmd === "auth login") {
|
|
299
|
+
const profile = validateProfileName(value(flags, "profile") ?? process.env.PYAI_PROFILE ?? "default");
|
|
300
|
+
if (flags["key-stdin"] && value(flags, "api-key")) usage("Choose --key-stdin or --api-key");
|
|
301
|
+
const explicitKey = flags["key-stdin"] || flags["api-key"] !== undefined;
|
|
302
|
+
if (explicitKey && (flags.web || flags["no-browser"] || flags["login-timeout"] !== undefined)) usage("Browser login options cannot be combined with --key-stdin or --api-key");
|
|
303
|
+
if (!explicitKey) {
|
|
304
|
+
const { baseURL } = await resolveConfig({ baseURL: value(flags, "base-url"), profile, allowMissingProfile: true, ignoreApiKey: true });
|
|
305
|
+
const timeoutMs = number(flags, "timeout", 30, 0.001) * 1000;
|
|
306
|
+
const loginTimeoutMs = number(flags, "login-timeout", 600, 0.001, 3600) * 1000;
|
|
307
|
+
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; }
|
|
308
|
+
const result = await browserLogin({ http: new CliHttp({ baseURL, timeoutMs, maxRetries: 0 }), baseURL,
|
|
309
|
+
noBrowser: flags["no-browser"] === true || (!!process.env.CI && !flags.web), timeoutMs: loginTimeoutMs, requestTimeoutMs: timeoutMs,
|
|
310
|
+
onSecret: key => secrets.add(key),
|
|
311
|
+
onAuthorization: notice => {
|
|
312
|
+
if (flags.json) process.stderr.write(`${JSON.stringify(notice)}\n`);
|
|
313
|
+
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`);
|
|
314
|
+
},
|
|
315
|
+
onBrowserUnavailable: () => {
|
|
316
|
+
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");
|
|
317
|
+
},
|
|
318
|
+
});
|
|
319
|
+
await saveProfile(profile, { api_key: result.api_key, base_url: baseURL });
|
|
320
|
+
const { api_key: _key, ...metadata } = result;
|
|
321
|
+
output({ ...metadata, profile, base_url: baseURL, saved: true }, flags, `Signed in. Saved ${result.environment} credentials for profile ${profile}.`);
|
|
322
|
+
return;
|
|
277
323
|
}
|
|
324
|
+
const key = flags["key-stdin"] ? (await stdin()).toString("utf8").trim() : required(flags, "api-key");
|
|
325
|
+
secrets.add(key); validateApiKey(key);
|
|
326
|
+
const { baseURL } = await resolveConfig({ apiKey: key, baseURL: value(flags, "base-url"), profile, allowMissingProfile: true });
|
|
327
|
+
if (!flags["dry-run"]) await saveProfile(profile, { api_key: key, base_url: baseURL });
|
|
328
|
+
output({ profile, base_url: baseURL, saved: !flags["dry-run"] }, flags, `${flags["dry-run"] ? "Would save" : "Saved"} credentials for ${profile}`); return;
|
|
278
329
|
}
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
await
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
await
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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}`);
|
|
330
|
+
const sandbox = cmd === "auth sandbox";
|
|
331
|
+
const sandboxProfile = sandbox ? validateProfileName(value(flags, "profile") ?? process.env.PYAI_PROFILE ?? "sandbox") : undefined;
|
|
332
|
+
const config = await resolveConfig({ apiKey: value(flags, "api-key"), baseURL: value(flags, "base-url"), profile: sandboxProfile ?? value(flags, "profile"), allowMissingProfile: sandbox });
|
|
333
|
+
if (config.apiKey) secrets.add(config.apiKey);
|
|
334
|
+
const retries = number(flags, "retries", 2, 0, 5); if (!Number.isInteger(retries)) usage("--retries must be an integer");
|
|
335
|
+
const http = new CliHttp({ baseURL: config.baseURL, apiKey: config.apiKey, timeoutMs: number(flags, "timeout", 30, 0.001) * 1000, maxRetries: retries });
|
|
336
|
+
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; }
|
|
337
|
+
if (sandbox) {
|
|
338
|
+
if (preview(flags, config.baseURL, "POST", "/v1/sandbox/keys", {})) return;
|
|
339
|
+
const result = await http.json("POST", "/v1/sandbox/keys", { auth: false, json: {} });
|
|
340
|
+
if (!result || typeof result.api_key !== "string") throw new CliError("invalid_response", "Sandbox response did not contain an API key");
|
|
341
|
+
secrets.add(result.api_key); validateApiKey(result.api_key);
|
|
342
|
+
const profile = sandboxProfile!;
|
|
343
|
+
await saveProfile(profile, { api_key: result.api_key, base_url: config.baseURL });
|
|
344
|
+
const { api_key: _key, ...metadata } = result;
|
|
345
|
+
output({ ...metadata, profile, saved: true }, flags, `Sandbox ready. Saved credentials in profile ${profile}.`); return;
|
|
298
346
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
} else {
|
|
303
|
-
out(`\nDiagnosis: ${failed.length} check(s) failed, see the remediation hints above.`);
|
|
304
|
-
process.exit(1);
|
|
347
|
+
if (cmd === "smoke" || cmd === "doctor") {
|
|
348
|
+
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; }
|
|
349
|
+
await diagnostics(http, cmd, flags); return;
|
|
305
350
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
351
|
+
if (cmd === "speak") {
|
|
352
|
+
if (flags.text === undefined && flags["text-file"] === undefined && !process.stdin.isTTY) flags["text-file"] = "-";
|
|
353
|
+
if (!!value(flags, "text") === !!value(flags, "text-file")) usage("Provide exactly one of --text or --text-file (use - for stdin)");
|
|
354
|
+
const text = value(flags, "text") ?? (await inputFile(required(flags, "text-file"))).toString("utf8");
|
|
355
|
+
if (!text.trim()) usage("Speech text is empty");
|
|
356
|
+
const format = value(flags, "format") ?? "wav";
|
|
357
|
+
if (!(SPEECH_FORMATS as readonly string[]).includes(format)) usage(`--format must be one of ${SPEECH_FORMATS.join(", ")}`);
|
|
358
|
+
const sampleRate = value(flags, "sample-rate") === undefined ? undefined : number(flags, "sample-rate", 24000, 8000, 48000);
|
|
359
|
+
if (sampleRate !== undefined && !(SPEECH_SAMPLE_RATES as readonly number[]).includes(sampleRate)) usage(`--sample-rate must be one of ${SPEECH_SAMPLE_RATES.join(", ")}`);
|
|
360
|
+
if (format.startsWith("g711_") && sampleRate !== undefined && sampleRate !== 8000) usage("G.711 audio requires --sample-rate 8000");
|
|
361
|
+
const payload = { input: text, model: value(flags, "model") ?? "pyai-speak", voice: value(flags, "voice"), response_format: format, sample_rate: sampleRate };
|
|
362
|
+
const path = value(flags, "out") ?? `pyai-speak.${format.startsWith("g711_") ? "raw" : format}`;
|
|
363
|
+
await checkOutput(path, flags);
|
|
364
|
+
if (preview(flags, config.baseURL, "POST", "/v1/audio/speech", payload)) return;
|
|
365
|
+
await writeAudio(await http.request("POST", "/v1/audio/speech", { json: payload }), path, flags); return;
|
|
366
|
+
}
|
|
367
|
+
if (cmd === "transcribe") {
|
|
368
|
+
if (flags["text-only"] && flags.json) usage("--text-only cannot be combined with --json");
|
|
369
|
+
if (!!value(flags, "file") === !!value(flags, "url")) usage("Provide exactly one of --file or --url");
|
|
370
|
+
const isJob = !!value(flags, "url");
|
|
371
|
+
if (flags["text-only"] && isJob && !flags.wait && !flags.poll) usage("--text-only with a URL requires --wait");
|
|
372
|
+
if (!isJob && (flags.diarize || flags.wait || flags.poll || flags["idempotency-key"])) usage("--diarize, --wait, --poll and --idempotency-key require --url (async jobs)");
|
|
373
|
+
const path = isJob ? "/v1/transcription/jobs" : "/v1/audio/transcriptions";
|
|
374
|
+
const body = isJob ? { audio_url: value(flags, "url"), diarize: flags.diarize === true, ...(value(flags, "language") ? { language: value(flags, "language") } : {}) } : await upload(flags, "file");
|
|
375
|
+
if (body instanceof FormData) { body.set("model", "pyai-hear"); if (value(flags, "language")) body.set("language", value(flags, "language")!); }
|
|
376
|
+
if (flags.wait || flags.poll) { number(flags, "wait-timeout", 120, 0.001); number(flags, "poll-interval", 2, 0.001, 60); }
|
|
377
|
+
if (preview(flags, config.baseURL, "POST", path, body)) return;
|
|
378
|
+
const result = await http.json("POST", path, { ...(body instanceof FormData ? { body } : { json: body }), headers: headers(flags) });
|
|
379
|
+
if (isJob && (flags.wait || flags.poll)) {
|
|
380
|
+
if (!result || typeof result.job_id !== "string") throw new CliError("invalid_response", "Job response is missing job_id");
|
|
381
|
+
const completed = await waitForJob(http, `${path}/${safeId(result.job_id)}`, flags, ["completed"], ["failed", "cancelled"]);
|
|
382
|
+
transcriptOutput(completed, flags, `${path}/${safeId(result.job_id)}`);
|
|
383
|
+
} else transcriptOutput(result, flags);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (cmd === "dub") {
|
|
387
|
+
const language = required(flags, "to");
|
|
388
|
+
const out = value(flags, "out") ?? "pyai-dub.wav";
|
|
389
|
+
await checkOutput(out, flags);
|
|
390
|
+
number(flags, "wait-timeout", 120, 0.001); number(flags, "poll-interval", 2, 0.001, 60);
|
|
391
|
+
const body = await dubForm(flags, language, value(flags, "from"));
|
|
392
|
+
if (flags["dry-run"]) {
|
|
393
|
+
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;
|
|
394
|
+
}
|
|
395
|
+
const created = await http.json("POST", "/v1/dub", { body, headers: headers(flags) });
|
|
396
|
+
if (!created || typeof created.job_id !== "string" || !created.job_id) throw new CliError("invalid_response", "Dub response is missing job_id");
|
|
397
|
+
const path = `/v1/dub/jobs/${safeId(created.job_id)}`;
|
|
398
|
+
if (!flags.json) process.stderr.write(`Dub job ${redact(created.job_id)} submitted. Waiting for audio…\n`);
|
|
399
|
+
try {
|
|
400
|
+
await waitForJob(http, path, flags, ["done"], ["error"]);
|
|
401
|
+
await writeAudio(await http.request("GET", `${path}/audio`), out, flags, { job_id: created.job_id, status: "done" });
|
|
402
|
+
} catch (error) {
|
|
403
|
+
if (error instanceof CliError) throw new CliError(error.code, error.message, error.exitCode, { ...error.details, job_id: created.job_id, path });
|
|
404
|
+
throw new CliError("local_error", (error as Error).message, 2, { job_id: created.job_id, path });
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (cmd === "clones create" || cmd === "dub create") {
|
|
409
|
+
let form: FormData;
|
|
410
|
+
if (cmd === "clones create") { const name = required(flags, "name"); form = await upload(flags, "file"); form.set("name", name); }
|
|
411
|
+
else form = await dubForm(flags, required(flags, "language"), value(flags, "source-language"));
|
|
412
|
+
const path = cmd === "clones create" ? "/v1/voice/clones" : "/v1/dub";
|
|
413
|
+
if (preview(flags, config.baseURL, "POST", path, form)) return;
|
|
414
|
+
output(await http.json("POST", path, { body: form, headers: headers(flags) }), flags); return;
|
|
415
|
+
}
|
|
416
|
+
if (cmd === "request") {
|
|
417
|
+
const method = args[0]!.toUpperCase(); if (!["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"].includes(method)) usage("Unsupported HTTP method");
|
|
418
|
+
let path = args[1]!;
|
|
419
|
+
if (!/^\/(?!\/)/.test(path) || path.includes("\\") || /[\x00-\x20\x7f]/.test(path)) usage("Use a relative API path such as /v1/models");
|
|
420
|
+
const q = new URLSearchParams();
|
|
421
|
+
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)); }
|
|
422
|
+
if (q.toString()) path += `${path.includes("?") ? "&" : "?"}${q}`;
|
|
423
|
+
http.validatePath(path);
|
|
424
|
+
const data = flags.data !== undefined ? await jsonInput(flags) : undefined;
|
|
425
|
+
if (data !== undefined && ["GET", "HEAD"].includes(method)) usage(`${method} does not accept --data`);
|
|
426
|
+
const out = value(flags, "out"); if (out) await checkOutput(out, flags);
|
|
427
|
+
if (preview(flags, config.baseURL, method, path, data)) return;
|
|
428
|
+
if (out) await writeAudio(await http.request(method, path, { json: data, headers: headers(flags) }), out, flags);
|
|
429
|
+
else output(await http.json(method, path, { json: data, headers: headers(flags) }), flags);
|
|
430
|
+
return;
|
|
330
431
|
}
|
|
432
|
+
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" };
|
|
433
|
+
if (cmd in simplePaths) {
|
|
434
|
+
const path = withQuery(simplePaths[cmd]!, Object.fromEntries(["gender", "region", "language", "tier", "q", "source"].map(k => [k, value(flags, k)])));
|
|
435
|
+
if (preview(flags, config.baseURL, "GET", path)) return;
|
|
436
|
+
output(await http.json("GET", path, { auth: cmd !== "schema" }), flags, undefined, cmd !== "schema"); return;
|
|
437
|
+
}
|
|
438
|
+
const route = routes.find(r => r.command === cmd)!;
|
|
439
|
+
let path = route.path.replace("{id}", route.id ? safeId(args[0]!) : "");
|
|
440
|
+
if (value(flags, "limit") !== undefined) { const limit = number(flags, "limit", 20, 1, 100); if (!Number.isInteger(limit)) usage("--limit must be an integer"); }
|
|
441
|
+
path = withQuery(path, { limit: value(flags, "limit"), cursor: value(flags, "cursor") });
|
|
442
|
+
const data = route.body ? await jsonInput(flags, true) : undefined;
|
|
443
|
+
const out = route.binary ? required(flags, "out") : undefined;
|
|
444
|
+
if (out) await checkOutput(out, flags);
|
|
445
|
+
if (preview(flags, config.baseURL, route.method, path, data)) return;
|
|
446
|
+
if (route.wait) output(await waitForJob(http, path, flags, route.wait.success, route.wait.failure), flags);
|
|
447
|
+
else if (out) await writeAudio(await http.request(route.method, path, { json: data, headers: headers(flags) }), out, flags);
|
|
448
|
+
else output(await http.json(route.method, path, { json: data, headers: headers(flags) }), flags);
|
|
331
449
|
}
|
|
332
450
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
451
|
+
for (const stream of [process.stdout, process.stderr]) stream.on("error", err => { if ((err as NodeJS.ErrnoException).code === "EPIPE") process.exit(0); throw err; });
|
|
452
|
+
process.once("SIGINT", () => {
|
|
453
|
+
for (const path of pendingAudioFiles) { try { unlinkSync(path); } catch { /* Best effort cleanup on interruption. */ } }
|
|
454
|
+
process.stderr.write(jsonMode ? `${JSON.stringify({ error: { code: "interrupted", message: "Interrupted" } })}\n` : "pyai: interrupted\n");
|
|
455
|
+
process.exit(130);
|
|
456
|
+
});
|
|
457
|
+
if (process.env.PYAI_API_KEY) secrets.add(process.env.PYAI_API_KEY);
|
|
458
|
+
// Register explicit keys before parsing so even invalid-command errors cannot echo them.
|
|
459
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
460
|
+
const a = process.argv[i]!;
|
|
461
|
+
const key = a.startsWith("--api-key=") ? a.slice(10) : a === "--api-key" ? process.argv[i + 1] : undefined;
|
|
462
|
+
if (key) secrets.add(key);
|
|
463
|
+
}
|
|
464
|
+
main().catch(err => {
|
|
465
|
+
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);
|
|
466
|
+
const detail = { code: e.code, message: e.message, ...e.details };
|
|
467
|
+
if (jsonMode) process.stderr.write(`${JSON.stringify({ error: redact(detail) })}\n`);
|
|
468
|
+
else {
|
|
469
|
+
process.stderr.write(`pyai: ${redact(e.message)} (${redact(e.code)})\n`);
|
|
470
|
+
if (typeof e.details?.path === "string") process.stderr.write(`Reference: ${redact(e.details.path)}\n`);
|
|
471
|
+
if (typeof e.details?.job_id === "string") process.stderr.write(`Job ID: ${redact(e.details.job_id)}\n`);
|
|
336
472
|
}
|
|
337
|
-
|
|
473
|
+
process.exitCode = e.exitCode;
|
|
338
474
|
});
|