@pyai/sdk 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENT_GUIDE.md +300 -0
- package/CLI.md +634 -0
- package/CLI.schema.json +1327 -0
- package/README.md +25 -22
- package/dist/cli-config.d.ts +47 -0
- package/dist/cli-config.js +265 -0
- package/dist/cli-dx.d.ts +12 -0
- package/dist/cli-dx.js +38 -0
- package/dist/cli-http.d.ts +38 -0
- package/dist/cli-http.js +295 -0
- package/dist/cli-init.d.ts +27 -0
- package/dist/cli-init.js +433 -0
- package/dist/cli-routes.d.ts +18 -0
- package/dist/cli-routes.js +65 -0
- package/dist/cli-runtime.d.ts +10 -0
- package/dist/cli-runtime.js +23 -0
- package/dist/cli-web-auth.d.ts +33 -0
- package/dist/cli-web-auth.js +154 -0
- package/dist/cli.d.ts +1 -17
- package/dist/cli.js +683 -270
- package/dist/index.d.ts +22 -4
- package/dist/index.js +13 -0
- package/package.json +7 -2
- package/src/cli-config.ts +283 -0
- package/src/cli-dx.ts +39 -0
- package/src/cli-http.ts +280 -0
- package/src/cli-init.ts +433 -0
- package/src/cli-routes.ts +88 -0
- package/src/cli-runtime.ts +30 -0
- package/src/cli-web-auth.ts +148 -0
- package/src/cli.ts +439 -295
- package/src/index.ts +30 -5
package/dist/index.d.ts
CHANGED
|
@@ -134,8 +134,19 @@ export interface SpeechParams {
|
|
|
134
134
|
*/
|
|
135
135
|
temperature?: number;
|
|
136
136
|
}
|
|
137
|
-
export
|
|
137
|
+
export type CreateJobParams = CreateJobOptions & ({
|
|
138
138
|
audio_url: string;
|
|
139
|
+
gpt_live?: never;
|
|
140
|
+
} | {
|
|
141
|
+
gpt_live: {
|
|
142
|
+
session_id: string;
|
|
143
|
+
};
|
|
144
|
+
audio_url?: never;
|
|
145
|
+
});
|
|
146
|
+
export interface CreateJobOptions {
|
|
147
|
+
/** Requires Trace entitlement; incompatible with channel/diarize. */
|
|
148
|
+
trace?: boolean;
|
|
149
|
+
rule_pack?: Record<string, unknown>;
|
|
139
150
|
model?: string;
|
|
140
151
|
diarize?: boolean;
|
|
141
152
|
channel?: boolean;
|
|
@@ -535,6 +546,8 @@ export declare const OmniEvent: {
|
|
|
535
546
|
readonly Hello: "hello";
|
|
536
547
|
/** Ack for your `configure` frame (echoes the resolved `voice_id`). */
|
|
537
548
|
readonly Configured: "configured";
|
|
549
|
+
/** Served voice capabilities changed, for example after synthesis fallback. */
|
|
550
|
+
readonly VoiceCapabilities: "voice_capabilities";
|
|
538
551
|
/** Session is live; includes the resolved agent + audio caps. */
|
|
539
552
|
readonly SessionStarted: "session_started";
|
|
540
553
|
/** Turn boundary (user/assistant speaking). */
|
|
@@ -1085,16 +1098,21 @@ export interface AmdCall extends AmdCallSummary {
|
|
|
1085
1098
|
}
|
|
1086
1099
|
/**
|
|
1087
1100
|
* A mid-call AMD decision event pushed on the stream (and to the per-call
|
|
1088
|
-
* TwiML `webhook`). Carries the
|
|
1089
|
-
*
|
|
1090
|
-
* (`AmdCall`) and the `amd.call.completed` webhook instead.
|
|
1101
|
+
* TwiML `webhook`). Carries the routing class and an optional machine subtype.
|
|
1102
|
+
* Stored `AmdCall` records fold the subtype into `answered_by`.
|
|
1091
1103
|
*/
|
|
1092
1104
|
export interface AmdDecisionEvent {
|
|
1093
1105
|
event?: "amd";
|
|
1094
1106
|
call_id?: string;
|
|
1095
1107
|
answered_by?: AmdWireAnsweredBy;
|
|
1096
1108
|
answered_by_twilio?: string | null;
|
|
1109
|
+
subtype?: string;
|
|
1110
|
+
/** A human or automated answering party was identified. */
|
|
1111
|
+
party_detected?: boolean;
|
|
1112
|
+
/** Classification events return false; detection does not establish recording readiness. */
|
|
1113
|
+
voicemail_ready?: boolean;
|
|
1097
1114
|
confidence?: number | null;
|
|
1115
|
+
/** Processed inbound audio through the decision, not time since carrier answer. */
|
|
1098
1116
|
decision_ms?: number | null;
|
|
1099
1117
|
reason?: string | null;
|
|
1100
1118
|
[k: string]: unknown;
|
package/dist/index.js
CHANGED
|
@@ -286,6 +286,8 @@ export const OmniEvent = {
|
|
|
286
286
|
Hello: "hello",
|
|
287
287
|
/** Ack for your `configure` frame (echoes the resolved `voice_id`). */
|
|
288
288
|
Configured: "configured",
|
|
289
|
+
/** Served voice capabilities changed, for example after synthesis fallback. */
|
|
290
|
+
VoiceCapabilities: "voice_capabilities",
|
|
289
291
|
/** Session is live; includes the resolved agent + audio caps. */
|
|
290
292
|
SessionStarted: "session_started",
|
|
291
293
|
/** Turn boundary (user/assistant speaking). */
|
|
@@ -495,6 +497,17 @@ export class OmniConnection {
|
|
|
495
497
|
try {
|
|
496
498
|
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(1)));
|
|
497
499
|
if (parsed?.event === OmniEvent.Transcript) {
|
|
500
|
+
// The deployed engine also sends this bounded synthesis-input
|
|
501
|
+
// advisory. It is not caller ASR or proof that speech was played.
|
|
502
|
+
const keys = Object.keys(parsed);
|
|
503
|
+
const text = omniTranscriptText(parsed.text);
|
|
504
|
+
if (parsed.role === "assistant" && parsed.final === true && text?.trim()
|
|
505
|
+
&& keys.length === 4
|
|
506
|
+
&& keys.every((key) => ["event", "role", "text", "final"].includes(key))) {
|
|
507
|
+
this.opts.onEvent?.(parsed);
|
|
508
|
+
this.opts.onTranscript?.({ event: "transcript", role: "assistant", text, final: true, mode: "replace" });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
498
511
|
this.opts.onError?.(new Error("Omni transcript events must use a binary 0x02 frame"));
|
|
499
512
|
return;
|
|
500
513
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyai/sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Official TypeScript/JavaScript SDK for PyAI, speech-to-text (Hear), text-to-speech (Speak), realtime voice agents (Omni), and call compliance (Trace).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
19
|
"src",
|
|
20
|
-
"README.md"
|
|
20
|
+
"README.md",
|
|
21
|
+
"CLI.md",
|
|
22
|
+
"CLI.schema.json",
|
|
23
|
+
"AGENT_GUIDE.md"
|
|
21
24
|
],
|
|
22
25
|
"repository": {
|
|
23
26
|
"type": "git",
|
|
@@ -28,6 +31,8 @@
|
|
|
28
31
|
"bugs": "https://github.com/atomsai/pyai-platform-backend/issues",
|
|
29
32
|
"author": "PyAI",
|
|
30
33
|
"scripts": {
|
|
34
|
+
"gen:cli-assets": "node scripts/generate-cli-assets.mjs",
|
|
35
|
+
"check:cli-assets": "node scripts/generate-cli-assets.mjs --check",
|
|
31
36
|
"build": "tsc -p tsconfig.build.json",
|
|
32
37
|
"test": "node --test test/**/*.test.ts",
|
|
33
38
|
"typecheck": "tsc --noEmit"
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { chmod, lstat, mkdir, open, rename, unlink, type FileHandle } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_BASE_URL = "https://api.pyai.com";
|
|
8
|
+
|
|
9
|
+
export class CliConfigError extends Error {
|
|
10
|
+
readonly code: string;
|
|
11
|
+
|
|
12
|
+
constructor(code: string, message: string) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = "CliConfigError";
|
|
15
|
+
this.code = code;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CliProfile {
|
|
20
|
+
api_key?: string;
|
|
21
|
+
base_url?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CliConfig {
|
|
25
|
+
version: 1;
|
|
26
|
+
active_profile: string | null;
|
|
27
|
+
profiles: Record<string, CliProfile>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ResolvedConfig {
|
|
31
|
+
apiKey?: string;
|
|
32
|
+
baseURL: string;
|
|
33
|
+
profile: string;
|
|
34
|
+
/** Provenance of the API key. The base URL is resolved independently. */
|
|
35
|
+
source: "explicit" | "environment" | "profile" | "none";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ProfileSummary {
|
|
39
|
+
name: string;
|
|
40
|
+
active: boolean;
|
|
41
|
+
base_url: string;
|
|
42
|
+
has_api_key: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Keys are opaque: validation only ensures a bounded, header-safe value. */
|
|
46
|
+
export function validateApiKey(input: string): string {
|
|
47
|
+
if (typeof input !== "string" || input.length === 0 || input.length > 512 || /[\s\p{Cc}]/u.test(input)) {
|
|
48
|
+
throw new CliConfigError("invalid_api_key", "API keys must contain 1–512 characters without whitespace or control characters.");
|
|
49
|
+
}
|
|
50
|
+
return input;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The SDK adds /v1, so accept both documented API base URL forms. */
|
|
54
|
+
export function normalizeBaseURL(input: string): string {
|
|
55
|
+
const invalid = () => new CliConfigError("invalid_base_url", "Base URL must use HTTPS (or HTTP on localhost), without credentials, a query, or a fragment.");
|
|
56
|
+
if (typeof input !== "string" || !/^https?:\/\//i.test(input) || /[\s\p{Cc}?#]/u.test(input)) throw invalid();
|
|
57
|
+
let url: URL;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(input);
|
|
60
|
+
} catch {
|
|
61
|
+
throw invalid();
|
|
62
|
+
}
|
|
63
|
+
const authority = input.slice(input.indexOf("://") + 3).split("/")[0]!;
|
|
64
|
+
if (url.username || url.password || authority.includes("@")) throw invalid();
|
|
65
|
+
const hostname = url.hostname.toLowerCase();
|
|
66
|
+
const loopback = hostname === "localhost" || hostname === "localhost." || hostname === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(hostname);
|
|
67
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) throw invalid();
|
|
68
|
+
const path = url.pathname.replace(/\/+$/, "").replace(/\/v1$/, "").replace(/\/+$/, "");
|
|
69
|
+
return `${url.origin}${path}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function validateProfileName(name: string): string {
|
|
73
|
+
if (typeof name !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/.test(name)) {
|
|
74
|
+
throw new CliConfigError("invalid_profile", "Profile names must be 1–64 letters, numbers, dots, underscores, or hyphens, starting with a letter or number.");
|
|
75
|
+
}
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function configDirectory(): string {
|
|
80
|
+
if (process.env.PYAI_CONFIG_DIR) return resolve(process.env.PYAI_CONFIG_DIR);
|
|
81
|
+
return join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "pyai");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function emptyConfig(): CliConfig {
|
|
85
|
+
return { version: 1, active_profile: null, profiles: {} };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function hasCode(error: unknown, code: string): boolean {
|
|
89
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
93
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function invalidConfig(): CliConfigError {
|
|
97
|
+
return new CliConfigError("config_invalid", "PyAI config.json is invalid or uses an unsupported version. Fix it or move it aside before retrying.");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function parseConfig(text: string): CliConfig {
|
|
101
|
+
try {
|
|
102
|
+
const parsed: unknown = JSON.parse(text);
|
|
103
|
+
if (!isRecord(parsed) || parsed.version !== 1 || !isRecord(parsed.profiles)) throw invalidConfig();
|
|
104
|
+
if (parsed.active_profile !== null && typeof parsed.active_profile !== "string") throw invalidConfig();
|
|
105
|
+
const profiles: Record<string, CliProfile> = {};
|
|
106
|
+
for (const [name, value] of Object.entries(parsed.profiles)) {
|
|
107
|
+
validateProfileName(name);
|
|
108
|
+
if (!isRecord(value) || Object.keys(value).some((key) => key !== "api_key" && key !== "base_url")) throw invalidConfig();
|
|
109
|
+
const profile: CliProfile = {};
|
|
110
|
+
if ("api_key" in value) profile.api_key = validateApiKey(value.api_key as string);
|
|
111
|
+
if ("base_url" in value) profile.base_url = normalizeBaseURL(value.base_url as string);
|
|
112
|
+
profiles[name] = profile;
|
|
113
|
+
}
|
|
114
|
+
if (parsed.active_profile !== null) {
|
|
115
|
+
validateProfileName(parsed.active_profile);
|
|
116
|
+
if (!Object.hasOwn(profiles, parsed.active_profile)) throw invalidConfig();
|
|
117
|
+
}
|
|
118
|
+
return { version: 1, active_profile: parsed.active_profile, profiles };
|
|
119
|
+
} catch {
|
|
120
|
+
// Never include JSON parser errors: they can contain the credential itself.
|
|
121
|
+
throw invalidConfig();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function verifyOwner(uid: number): void {
|
|
126
|
+
if (typeof process.getuid === "function" && uid !== process.getuid()) {
|
|
127
|
+
throw new CliConfigError("config_unsafe", "PyAI configuration must be owned by the current user.");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function inspectDirectory(directory: string): Promise<boolean> {
|
|
132
|
+
try {
|
|
133
|
+
const stat = await lstat(directory);
|
|
134
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
135
|
+
throw new CliConfigError("config_unsafe", "PyAI configuration directory must be a real directory, not a symbolic link.");
|
|
136
|
+
}
|
|
137
|
+
verifyOwner(stat.uid);
|
|
138
|
+
return true;
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (hasCode(error, "ENOENT")) return false;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function readConfigAt(directory: string): Promise<CliConfig> {
|
|
146
|
+
let file: FileHandle | undefined;
|
|
147
|
+
try {
|
|
148
|
+
if (!(await inspectDirectory(directory))) return emptyConfig();
|
|
149
|
+
const path = join(directory, "config.json");
|
|
150
|
+
const stat = await lstat(path);
|
|
151
|
+
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
152
|
+
throw new CliConfigError("config_unsafe", "PyAI config.json must be a regular file, not a symbolic link.");
|
|
153
|
+
}
|
|
154
|
+
file = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
155
|
+
const openedStat = await file.stat();
|
|
156
|
+
verifyOwner(openedStat.uid);
|
|
157
|
+
if (!openedStat.isFile() || openedStat.size > 1024 * 1024) throw invalidConfig();
|
|
158
|
+
return parseConfig(await file.readFile("utf8"));
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (hasCode(error, "ENOENT")) return emptyConfig();
|
|
161
|
+
if (error instanceof CliConfigError) throw error;
|
|
162
|
+
throw new CliConfigError("config_read_failed", "Unable to read PyAI config.json. Check the configuration directory and file permissions.");
|
|
163
|
+
} finally {
|
|
164
|
+
await file?.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Raw configuration is for internal use; use listProfiles for display. */
|
|
169
|
+
export async function readConfig(): Promise<CliConfig> {
|
|
170
|
+
return readConfigAt(configDirectory());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function writeConfigAt(directory: string, config: CliConfig): Promise<void> {
|
|
174
|
+
const temporary = join(directory, `.config-${randomUUID()}.tmp`);
|
|
175
|
+
let file: FileHandle | undefined;
|
|
176
|
+
try {
|
|
177
|
+
file = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
178
|
+
await file.writeFile(`${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
179
|
+
await file.sync();
|
|
180
|
+
await file.close();
|
|
181
|
+
file = undefined;
|
|
182
|
+
await rename(temporary, join(directory, "config.json"));
|
|
183
|
+
} finally {
|
|
184
|
+
await file?.close();
|
|
185
|
+
await unlink(temporary).catch((error: unknown) => {
|
|
186
|
+
if (!hasCode(error, "ENOENT")) throw error;
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function updateConfig(update: (config: CliConfig) => void): Promise<void> {
|
|
192
|
+
const directory = configDirectory();
|
|
193
|
+
const lockPath = join(directory, "config.json.lock");
|
|
194
|
+
let lock: FileHandle | undefined;
|
|
195
|
+
try {
|
|
196
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
197
|
+
await inspectDirectory(directory);
|
|
198
|
+
await chmod(directory, 0o700);
|
|
199
|
+
// Every writer holds an exclusive lock across read, modification, and rename.
|
|
200
|
+
// A crashed writer leaves a visible lock instead of risking lost updates.
|
|
201
|
+
const deadline = Date.now() + 5000;
|
|
202
|
+
while (!lock) {
|
|
203
|
+
try {
|
|
204
|
+
lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (!hasCode(error, "EEXIST")) throw error;
|
|
207
|
+
if (Date.now() >= deadline) {
|
|
208
|
+
throw new CliConfigError("config_busy", "PyAI configuration is locked by another process. Retry shortly; remove config.json.lock only if no PyAI configuration command is running.");
|
|
209
|
+
}
|
|
210
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
const config = await readConfigAt(directory);
|
|
214
|
+
update(config);
|
|
215
|
+
await writeConfigAt(directory, config);
|
|
216
|
+
} catch (error) {
|
|
217
|
+
if (error instanceof CliConfigError) throw error;
|
|
218
|
+
throw new CliConfigError("config_write_failed", "Unable to save PyAI configuration. Check the configuration directory and file permissions.");
|
|
219
|
+
} finally {
|
|
220
|
+
if (lock) {
|
|
221
|
+
await lock.close();
|
|
222
|
+
await unlink(lockPath);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Merge supplied fields and make this the active profile. */
|
|
228
|
+
export async function saveProfile(name: string, fields: CliProfile): Promise<void> {
|
|
229
|
+
validateProfileName(name);
|
|
230
|
+
const normalized: CliProfile = {};
|
|
231
|
+
if (fields.api_key !== undefined) normalized.api_key = validateApiKey(fields.api_key);
|
|
232
|
+
if (fields.base_url !== undefined) normalized.base_url = normalizeBaseURL(fields.base_url);
|
|
233
|
+
await updateConfig((config) => {
|
|
234
|
+
const previous = Object.hasOwn(config.profiles, name) ? config.profiles[name] : undefined;
|
|
235
|
+
config.profiles[name] = { ...previous, ...normalized };
|
|
236
|
+
config.active_profile = name;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function removeProfile(name: string): Promise<void> {
|
|
241
|
+
validateProfileName(name);
|
|
242
|
+
await updateConfig((config) => {
|
|
243
|
+
if (!Object.hasOwn(config.profiles, name)) throw new CliConfigError("profile_not_found", "The selected PyAI profile does not exist. Use auth login to create it.");
|
|
244
|
+
delete config.profiles[name];
|
|
245
|
+
if (config.active_profile === name) {
|
|
246
|
+
config.active_profile = Object.hasOwn(config.profiles, "default") ? "default" : Object.keys(config.profiles).sort()[0] ?? null;
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function useProfile(name: string): Promise<void> {
|
|
252
|
+
validateProfileName(name);
|
|
253
|
+
await updateConfig((config) => {
|
|
254
|
+
if (!Object.hasOwn(config.profiles, name)) throw new CliConfigError("profile_not_found", "The selected PyAI profile does not exist. Use auth login to create it.");
|
|
255
|
+
config.active_profile = name;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function resolveConfig(options: { apiKey?: string; baseURL?: string; profile?: string; allowMissingProfile?: boolean; ignoreApiKey?: boolean } = {}): Promise<ResolvedConfig> {
|
|
260
|
+
const config = await readConfig();
|
|
261
|
+
const selected = options.profile ?? (process.env.PYAI_PROFILE || undefined);
|
|
262
|
+
const profile = validateProfileName(selected ?? config.active_profile ?? "default");
|
|
263
|
+
const stored = Object.hasOwn(config.profiles, profile) ? config.profiles[profile] : undefined;
|
|
264
|
+
if (selected !== undefined && !stored && !options.allowMissingProfile) {
|
|
265
|
+
throw new CliConfigError("profile_not_found", "The selected PyAI profile does not exist. Use auth login to create it.");
|
|
266
|
+
}
|
|
267
|
+
const environmentKey = process.env.PYAI_API_KEY || undefined;
|
|
268
|
+
const apiKey = options.ignoreApiKey ? undefined : options.apiKey ?? environmentKey ?? stored?.api_key;
|
|
269
|
+
const baseURL = normalizeBaseURL(options.baseURL ?? (process.env.PYAI_BASE_URL || undefined) ?? stored?.base_url ?? DEFAULT_BASE_URL);
|
|
270
|
+
const source = options.ignoreApiKey ? "none" : options.apiKey !== undefined ? "explicit" : environmentKey !== undefined ? "environment" : stored?.api_key !== undefined ? "profile" : "none";
|
|
271
|
+
return { ...(apiKey !== undefined ? { apiKey: validateApiKey(apiKey) } : {}), baseURL, profile, source };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Never reveal a key, including its prefix or suffix, in profile listings. */
|
|
275
|
+
export async function listProfiles(): Promise<ProfileSummary[]> {
|
|
276
|
+
const config = await readConfig();
|
|
277
|
+
return Object.keys(config.profiles).sort().map((name) => ({
|
|
278
|
+
name,
|
|
279
|
+
active: config.active_profile === name,
|
|
280
|
+
base_url: config.profiles[name]!.base_url ?? DEFAULT_BASE_URL,
|
|
281
|
+
has_api_key: config.profiles[name]!.api_key !== undefined,
|
|
282
|
+
}));
|
|
283
|
+
}
|
package/src/cli-dx.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Offline discovery and copyable workflows; recipes never execute commands. */
|
|
2
|
+
export const aliases: Record<string, string> = Object.assign(Object.create(null), {
|
|
3
|
+
login: "auth login", logout: "auth logout", whoami: "auth status", use: "profiles use",
|
|
4
|
+
say: "speak", hear: "transcribe",
|
|
5
|
+
});
|
|
6
|
+
export const shortFlags: Record<string, string> = Object.assign(Object.create(null), {
|
|
7
|
+
"-h": "--help", "-v": "--version", "-o": "--out", "-f": "--file",
|
|
8
|
+
"-t": "--text", "-p": "--profile", "-j": "--json",
|
|
9
|
+
});
|
|
10
|
+
export interface Recipe { name: string; title: string; description: string; commands: string[]; notes: string[]; docs_url: string }
|
|
11
|
+
export const recipes: Recipe[] = [
|
|
12
|
+
{ name: "calling", title: "Set up a managed phone Agent", description: "Inspect numbers, bind a profile, and retrieve call artifacts through the public API.",
|
|
13
|
+
commands: ["pyai whoami --json", "pyai numbers list --json", "pyai numbers search --country US --limit 5 --json", "pyai agents list --json", "pyai numbers bind NUMBER_ID --data @binding.json --dry-run --json", "pyai calls create --data @call.json --idempotency-key CALL_REQUEST_KEY --dry-run --json", "pyai calls get RETURNED_CALL_ID --json", "pyai omni get OMNI_CALL_ID --json"],
|
|
14
|
+
notes: ["Replace all placeholder IDs with returned values. binding.json is {agent_id: RETURNED_AGENT_ID}; call.json requires from_number_id, to, agent_id and use_case from live OpenAPI.", "Remove --dry-run for authorized changes. numbers buy and calls create require --confirm and --idempotency-key. Calls reach real destinations; sandbox keys do not grant telephony permissions.", "Requires telephony:manage; session artifacts require omni:read. Preserve artifacts.omni_call_id. Read the decision tree for purchase, polling, recording, and inbound-call steps.", "Use calls wait ID --wait-timeout 120 --poll-interval 3 --json for bounded waiting. dispatch_unknown requires inspection, never automatic redial."] },
|
|
15
|
+
{ name: "omni", title: "Build a realtime conversation with the SDK", description: "Use the official SDK for binary framing, subprotocol auth, and typed events.",
|
|
16
|
+
commands: ["pyai schema --openapi --json", "pyai init voice-demo --template typescript", "pyai voices --json"],
|
|
17
|
+
notes: ["The starter supplies Hear/Speak examples. Add realtime through pyai.omni.connect({ rate, configure }); see the linked decision tree's SDK pattern.", "Wait for configured before non-silent input. Stream paced digital silence during pauses. Read hello.audio_out and clear queued playback on barge_in/flush.", "Browser code uses an ephemeral token minted by omni.createSession on your server. Never put live keys in browser code or URLs.", "Machine framing contract: https://api.pyai.com/omni-frames.json. For an existing Twilio number use @pyai/twilio and the official twilio-omni-voice-agent example."] },
|
|
18
|
+
{ name: "auth", title: "Sign in and check access", description: "Save a named profile and inspect the current credential.",
|
|
19
|
+
commands: ["pyai login --profile work", "pyai whoami --profile work --json", "pyai use work"],
|
|
20
|
+
notes: ["Browser login requires the CLI login API and console deployment. Use --no-browser on remote terminals.", "For unattended runs, supply PYAI_API_KEY through the environment. Environment values override saved profiles."] },
|
|
21
|
+
{ name: "speak", title: "Turn text into audio", description: "Speak a line, narrate a text file, or pipe text from another command.",
|
|
22
|
+
commands: ['pyai speak "Hello from PyAI" -o hello.wav', "pyai speak --text-file script.txt -o narration.wav", 'printf "Build complete.\\n" | pyai speak -o build.wav'],
|
|
23
|
+
notes: ["Files must have a new output name unless you pass --force. Default output is pyai-speak.wav.", "Use pyai voices --json to choose a current voice; add --voice ID."] },
|
|
24
|
+
{ name: "transcribe", title: "Get a transcript", description: "Print just the words or retain structured output for automation.",
|
|
25
|
+
commands: ["pyai hear call.wav --text-only > transcript.txt", "pyai transcribe call.wav --json > transcript.json", 'pyai transcribe "https://example.com/call.wav" --wait --text-only'],
|
|
26
|
+
notes: ["Replace the example URL with a reachable audio URL. Hosted audio uses an asynchronous job.", "--text-only requires --wait for URLs; large offloaded results retain a job reference instead of following an external URL with credentials."] },
|
|
27
|
+
{ name: "dub", title: "Dub audio in one command", description: "Submit a Dub job, wait for completion, and download the audio.",
|
|
28
|
+
commands: ["pyai request GET /healthz/dub --json", "pyai dub original.wav --from en --to hi -o dubbed.wav --wait-timeout 600"],
|
|
29
|
+
notes: ["Check the live health response for available input and output languages before choosing them. Requires dub:render.", "On a timeout, resume with pyai dub wait JOB_ID and pyai dub audio JOB_ID -o dubbed.wav; do not resubmit the source."] },
|
|
30
|
+
{ name: "agent", title: "Start a project for a coding agent", description: "Create local context and request examples without a network call.",
|
|
31
|
+
commands: ["pyai init voice-demo --template agent", "pyai schema agents create --json", "pyai agents create --data @voice-demo/agent.json --dry-run --json"],
|
|
32
|
+
notes: ["The destination directory must be new. The starter writes no credentials and installs no dependencies.", "Read the generated PYAI.md first. Fetch live OpenAPI before adding fields or endpoints; remove --dry-run only when ready to create the profile."] },
|
|
33
|
+
{ name: "inspect", title: "Discover before you call", description: "Inspect commands offline and fetch the current API contract.",
|
|
34
|
+
commands: ["pyai schema speak --json", "pyai schema agents --json", "pyai schema --openapi --json > openapi.json", "pyai request GET /v1/me --json"],
|
|
35
|
+
notes: ["Local schema and recipes work without credentials. Live OpenAPI needs a network connection but no API key.", "The CLI manages Agent profiles; use the SDK or WebSocket API for a live Omni audio session."] },
|
|
36
|
+
{ name: "ci", title: "Use PyAI in a build", description: "Check access and produce an audio artifact with machine-readable output.",
|
|
37
|
+
commands: ["pyai whoami --json", 'pyai speak "Your build is ready" -o build.wav --json', "pyai doctor --json"],
|
|
38
|
+
notes: ["Set PYAI_API_KEY through the CI secret store; do not put it in source or shell arguments.", "doctor synthesizes and transcribes a sample, which consumes usage. Use whoami for an authentication-only check.", "Install the CLI with npm install -g @pyai/sdk@0.6.0 and pin that version in repeatable builds."] },
|
|
39
|
+
].map(recipe => ({ ...recipe, docs_url: "https://pyai.com/agents/speech-calling.md" }));
|