Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,24 @@ telnyx-agent stt-providers --provider telnyx --service-type transcription --json

Output: `{ providers: [...] }`

### `telnyx-agent storage-sql-query`

**Run parameterized SQL against a Telnyx Storage SQL database.** The command
requires the database ID and preserves the generated Go CLI's binding syntax.
Repeat `--param` in positional `?` placeholder order; each value may be a
string, number, boolean, or `null`.

```bash
telnyx-agent storage-sql-query --id <database-id> --sql "SELECT * FROM users"
telnyx-agent storage-sql-query --id <database-id> \
--sql "SELECT * FROM users WHERE active = ? AND age >= ?" \
--param true --param 21 --json
```

Use bindings instead of interpolating values into SQL. Placeholder/parameter
count mismatches are rejected by the API. This command requires Telnyx Go CLI
v0.27.0 or newer; it does not change the package's vendored platform pin.

## Cookbook Copy Changes (for Deniz)

> **Status:** proposed copy changes for the *Communication API Cookbook v2* (the
Expand Down
2 changes: 1 addition & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
},
"scripts": {
"start": "node bin/telnyx-agent.mjs",
"test": "tsx --test tests/ai-assistants.test.ts tests/ai.test.ts tests/bin-launcher.test.ts tests/bugfixes.test.ts tests/client-network-errors.test.ts tests/edge-handoff.test.ts tests/fax.test.ts tests/idempotency.test.ts tests/integration.test.ts tests/iot.test.ts tests/messaging-profiles.test.ts tests/numbers.test.ts tests/platform-release.test.ts tests/porting.test.ts tests/postinstall.test.ts tests/rcs.test.ts tests/schedule-sms-rest.test.ts tests/setup-10dlc.test.ts tests/setup-assign-flags.test.ts tests/setup-verify.test.ts tests/setup-voice-edge.test.ts tests/setup-voice.test.ts tests/sms.test.ts tests/status-rest.test.ts tests/stt.test.ts tests/telnyx-cli-flags.test.ts tests/telnyx-cli-resolution.test.ts tests/tts.test.ts tests/unknown-flags.test.ts tests/verify.test.ts tests/version.test.ts tests/voice-connections.test.ts tests/voice.test.ts tests/whatsapp.test.ts",
"test": "tsx --test tests/ai-assistants.test.ts tests/ai.test.ts tests/bin-launcher.test.ts tests/bugfixes.test.ts tests/client-network-errors.test.ts tests/edge-handoff.test.ts tests/fax.test.ts tests/idempotency.test.ts tests/integration.test.ts tests/iot.test.ts tests/messaging-profiles.test.ts tests/numbers.test.ts tests/platform-release.test.ts tests/porting.test.ts tests/postinstall.test.ts tests/rcs.test.ts tests/schedule-sms-rest.test.ts tests/setup-10dlc.test.ts tests/setup-assign-flags.test.ts tests/setup-verify.test.ts tests/setup-voice-edge.test.ts tests/setup-voice.test.ts tests/sms.test.ts tests/status-rest.test.ts tests/storage-sql.test.ts tests/stt.test.ts tests/telnyx-cli-flags.test.ts tests/telnyx-cli-resolution.test.ts tests/tts.test.ts tests/unknown-flags.test.ts tests/verify.test.ts tests/version.test.ts tests/voice-connections.test.ts tests/voice.test.ts tests/whatsapp.test.ts",
"typecheck": "tsc --noEmit",
"postinstall": "tsx scripts/postinstall.ts",
"build": "npm run typecheck"
Expand Down
4 changes: 4 additions & 0 deletions cli/src/commands/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ const CAPABILITIES: Record<string, Capability[]> = {
"📡 IoT": [
{ name: "SIM Cards", description: "List, inspect, enable, and disable IoT SIM cards", actions: ["list_sim_cards", "retrieve_sim_card", "enable_sim_card", "disable_sim_card"] },
],
"🗄️ Storage": [
{ name: "SQL Databases", description: "Run parameterized SQL against a Telnyx Storage SQL database", actions: ["run_storage_sql_query"] },
],
"🔍 Lookup": [
{ name: "Number Lookup", description: "Carrier and caller ID lookups", actions: ["lookup_number"] },
],
Expand Down Expand Up @@ -142,6 +145,7 @@ const COMPOSITE_COMMANDS = [
{ name: "telnyx-agent search-phone-numbers", description: "Search available phone numbers by country, type, features, location, or number pattern" },
{ name: "telnyx-agent buy-phone-number", description: "Purchase one phone number and optionally assign its connection or messaging profile" },
{ name: "telnyx-agent lookup-number", description: "Look up carrier or caller-name information for an E.164 phone number" },
{ name: "telnyx-agent storage-sql-query", description: "Run SQL with positional parameter bindings against a Telnyx Storage SQL database" },
];

export async function capabilitiesCommand(flags: Record<string, string | boolean>): Promise<void> {
Expand Down
88 changes: 88 additions & 0 deletions cli/src/commands/storage-sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Agent-friendly SQL execution backed by the Stainless-generated Telnyx Go CLI.
*
* Keep the generated command and flag surface intact: `--id` identifies the SQL
* database, `--sql` is the statement or script, and each repeated `--param`
* contributes one positional binding. In particular, parameter values are
* forwarded verbatim so the Go CLI can parse strings, numbers, booleans, and
* null with its generated `[]any` request flag.
*/

import { telnyxCli, TelnyxCLIError } from "../telnyx-cli.ts";
import { failWith, outputJson, printError, printSuccess } from "../utils/output.ts";

const MINIMUM_CLI_VERSION = "0.27.0";

type Flags = Record<string, string | boolean>;
type Occurrences = Record<string, Array<string | boolean>>;
type JsonRecord = Record<string, unknown>;

export async function storageSqlQueryCommand(
flags: Flags,
occurrences: Occurrences = {},
): Promise<void> {
const jsonOutput = flags.json === true;
const databaseId = requiredString(flags, "id", "SQL database ID", jsonOutput);
const sql = requiredString(flags, "sql", "SQL query or statement", jsonOutput);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept SQL scripts that start with a line comment

When --sql is followed by a statement beginning with --—for example, a generated migration script starting with -- create tablesparseFlags treats the statement as another flag because it refuses to consume values beginning with --. As a result, flags.sql becomes boolean true and this required-field check rejects an otherwise valid SQL script; add a parsing form that can preserve such values, such as command-specific consumption or --sql=... support.

Useful? React with 👍 / 👎.

const args = ["storage:sqldbs:actions", "query", "--id", databaseId, "--sql", sql];

const params = occurrences.param ?? (flags.param === undefined ? [] : [flags.param]);
for (const param of params) {
if (typeof param !== "string") {
failWith("--param requires a value (repeat it once per positional ? placeholder)", jsonOutput);
}
args.push("--param", param);
}

try {
const response = await telnyxCli(args, { minimumVersion: MINIMUM_CLI_VERSION });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bundle the Go CLI version required by SQL queries

On a standard npm installation without a separately installed telnyx binary, scripts/postinstall.ts still downloads the v0.24.0 pin from src/platform-release.ts, but this minimum-version guard rejects it and then attempts a PATH fallback. Consequently, the newly advertised command fails before executing any query for the package's normal installation path; bundle v0.27.0 or keep this command unavailable until that pin lands.

AGENTS.md reference: AGENTS.md:L116-L123

Useful? React with 👍 / 👎.

if (jsonOutput) {
// Query rows, mutation metadata, counts, and timing are all useful to an
// agent, so preserve the complete generated response envelope.
outputJson(response);
return;
}

const data = asRecord(asRecord(response).data ?? response);
const results = Array.isArray(data.results) ? data.results : [];
printSuccess("SQL query completed!", {
"SQL Database ID": databaseId,
Success: typeof data.success === "boolean" ? data.success : "(not returned)",
"Rows returned": numberOrFallback(data.count, results.length),
"Duration (ms)": numberOrFallback(data.duration, "(not returned)"),
});
outputJson(response);
} catch (err) {
const message = errorMsg(err);
if (jsonOutput) outputJson({ error: message });
else printError(message);
process.exit(1);
}
}

function requiredString(
flags: Flags,
name: string,
description: string,
jsonOutput: boolean,
): string {
const value = flags[name];
if (typeof value !== "string" || value.length === 0) {
failWith(`--${name} is required (${description})`, jsonOutput);
}
return value;
}

function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : {};
}

function numberOrFallback(value: unknown, fallback: number | string): number | string {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}

function errorMsg(err: unknown): string {
if (err instanceof TelnyxCLIError) return err.stderr || err.message;
if (err instanceof Error) return err.message;
return String(err);
}
14 changes: 12 additions & 2 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import {
listActiveCallsCommand,
listVoiceConnectionsCommand,
} from "./commands/voice-connections.ts";
import { storageSqlQueryCommand } from "./commands/storage-sql.ts";
import { parseFlags, isBooleanFlag } from "./utils/output.ts";

// Version is read lazily so that `--version` works without loading any command modules.
Expand Down Expand Up @@ -155,6 +156,7 @@ Commands:
get-ai-assistant Retrieve an AI assistant by ID
update-ai-assistant Update an AI assistant by ID
delete-ai-assistant Delete an AI assistant by ID (requires --confirm)
storage-sql-query Run SQL against a Telnyx Storage SQL database

Global Flags:
--json Output structured JSON instead of human-readable text
Expand Down Expand Up @@ -511,6 +513,12 @@ IoT SIM Action Flags:
--page-size Results per page (list-sim-cards)
--sort Sort field; prefix with - for descending (list-sim-cards)

Storage SQL Query Flags:
--id <database-id> SQL database ID (required)
--sql <statement> SQL to execute; use positional ? placeholders (required)
--param <value> Positional binding in placeholder order; repeat for each ?
Values use the generated CLI syntax: string, number, boolean, or null

Environment:
TELNYX_API_KEY API key (or configure ~/.config/telnyx/config.json)

Expand Down Expand Up @@ -627,6 +635,7 @@ Examples:
telnyx-agent retrieve-sim-card --id <sim-card-id> --json
telnyx-agent enable-sim-card --id <sim-card-id> --json
telnyx-agent disable-sim-card --id <sim-card-id> --json
telnyx-agent storage-sql-query --id <database-id> --sql "SELECT * FROM users WHERE id = ?" --param 42 --json
`;

const COMMANDS: Record<string, (
Expand Down Expand Up @@ -697,6 +706,7 @@ const COMMANDS: Record<string, (
"retrieve-sim-card": retrieveSimCardCommand,
"enable-sim-card": enableSimCardCommand,
"disable-sim-card": disableSimCardCommand,
"storage-sql-query": storageSqlQueryCommand,
};

// Union of every flag any command reads (kept in sync with src/commands/*).
Expand Down Expand Up @@ -727,15 +737,15 @@ const KNOWN_FLAGS = new Set<string>([
"mms-fall-back-to-sms", "mms-transcoding", "mobile-only", "monochrome", "msisdn", "name", "name-contains", "national-destination-code", "network-id", "number-pool-settings",
"new-billing-phone-number", "number-type", "numbers", "old-provider", "opt-in-method",
"optin-message", "optout-message", "outbound-voice-profile-id", "output", "output-file",
"output-type", "page-number", "page-size", "parameters", "parent-support-key", "participant",
"output-type", "page-number", "page-size", "param", "parameters", "parent-support-key", "participant",
"payload", "phone", "phone-number", "phone-number-id", "phone-numbers", "port-type",
"preview-format", "privacy", "profile-name", "promote-to-main", "provider", "quality",
"queue-name", "record", "remaining-numbers-action", "requirement-group-id", "resource-group-id", "response-format", "retry-on-timeout",
"role", "rx", "sample-message", "sample-message-2", "sample1", "sample2", "send-at",
"service-tier", "service-type", "sim-card-group-id", "sip-address", "sole-prop", "sort", "source",
"start-message", "starts-with", "status", "stop", "stop-message", "stop-sequence", "store-media", "store-preview", "system",
"stream", "stream-type", "subject", "submit", "t38-enabled", "tag", "tags", "temperature", "thinking", "timeout",
"smart-encoding",
"smart-encoding", "sql",
"template-language", "template-name", "text", "text-type", "time-limit-secs", "timeout-secs",
"to", "tool", "tool-choice", "tool-ids", "top-k", "top-p", "transcription", "transcription-language",
"transcription-model", "ttl", "tx", "type", "url", "usecase", "user", "verification-id",
Expand Down
192 changes: 192 additions & 0 deletions cli/tests/storage-sql.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
/**
* Focused mock-binary coverage for Telnyx Storage SQL queries.
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const cliRoot = join(__dirname, "..");
const cliBin = join(cliRoot, "bin", "telnyx-agent.ts");

function setupFakeTelnyx(version = "0.27.0"): { logPath: string; env: NodeJS.ProcessEnv } {
const tempDir = mkdtempSync(join(tmpdir(), "telnyx-agent-storage-sql-"));
const binDir = join(tempDir, "bin");
const logPath = join(tempDir, "args.jsonl");
const fakeTelnyx = join(binDir, "telnyx");
mkdirSync(binDir, { recursive: true });

writeFileSync(fakeTelnyx, `#!/usr/bin/env node
const fs = require("node:fs");
const args = process.argv.slice(2);
fs.appendFileSync(process.env.TELNYX_FAKE_ARGS_LOG, JSON.stringify(args) + "\\n");
if (args[0] === "--version") {
console.log("telnyx version ${version}");
process.exit(0);
}
if (args[0] === "storage:sqldbs:actions" && args[1] === "query") {
console.log(JSON.stringify({
data: {
count: 1,
duration: 2.75,
meta: { changes: 0, duration: 2.1, last_row_id: 0, rows_read: 1, rows_written: 0 },
results: [{ id: 42, name: "Alice", active: true }],
success: true
}
}));
} else {
console.error("unexpected command: " + args.join(" "));
process.exit(2);
}
`);
chmodSync(fakeTelnyx, 0o755);

return {
logPath,
env: {
...process.env,
TELNYX_API_KEY: "KEY_fake_test",
TELNYX_CLI_PATH: fakeTelnyx,
TELNYX_FAKE_ARGS_LOG: logPath,
TELNYX_FRICTION_ENABLED: "false",
TELNYX_TELEMETRY_ENDPOINT: "",
},
};
}

function runCli(args: string[], env: NodeJS.ProcessEnv = process.env): {
stdout: string;
stderr: string;
status: number;
} {
const result = spawnSync(process.execPath, ["--import", "tsx", cliBin, ...args], {
cwd: cliRoot,
encoding: "utf8",
env,
timeout: 30_000,
});
assert.equal(result.error, undefined);
return {
stdout: result.stdout ?? "",
stderr: result.stderr ?? "",
status: result.status ?? 1,
};
}

function loggedArgs(logPath: string): string[][] {
if (!existsSync(logPath)) return [];
const contents = readFileSync(logPath, "utf8");
assert.ok(contents.endsWith("\n"), "fake binary must write a real newline after each JSON record");
assert.ok(!contents.endsWith("\n\n"), "fake binary must not write a blank JSONL record");
return contents.trimEnd().split("\n").map((line) => JSON.parse(line) as string[]);
}

function flagValues(args: string[], flag: string): string[] {
return args.flatMap((arg, index) => arg === flag ? [args[index + 1]] : []);
}

describe("Storage SQL query command", () => {
it("forwards the generated ID, SQL, and repeated typed binding fields verbatim", () => {
const fake = setupFakeTelnyx();
const result = runCli([
"storage-sql-query",
"--id", "sql-db-123",
"--sql", "SELECT * FROM users WHERE name = ? AND id = ? AND score >= ? AND active = ? AND deleted_at IS ? AND code = ?",
"--param", "alice",
"--param", "42",
"--param", "3.5",
"--param", "true",
"--param", "null",
"--param", "\"007\"",
"--json",
], fake.env);

assert.equal(result.status, 0, result.stderr);
assert.equal(result.stderr, "", "documented Storage SQL flags must not warn");
assert.deepEqual(JSON.parse(result.stdout), {
data: {
count: 1,
duration: 2.75,
meta: { changes: 0, duration: 2.1, last_row_id: 0, rows_read: 1, rows_written: 0 },
results: [{ id: 42, name: "Alice", active: true }],
success: true,
},
});

const invocations = loggedArgs(fake.logPath);
assert.deepEqual(invocations[0], ["--version"]);
const args = invocations[1];
assert.deepEqual(args.slice(0, 6), [
"storage:sqldbs:actions", "query", "--id", "sql-db-123", "--sql",
"SELECT * FROM users WHERE name = ? AND id = ? AND score >= ? AND active = ? AND deleted_at IS ? AND code = ?",
]);
assert.deepEqual(flagValues(args, "--param"), ["alice", "42", "3.5", "true", "null", "\"007\""]);
assert.deepEqual(args.slice(-2), ["--format", "json"]);
});

it("supports statements without bind parameters and exposes result metadata in human output", () => {
const fake = setupFakeTelnyx();
const result = runCli([
"storage-sql-query", "--id", "sql-db-123", "--sql", "CREATE TABLE users (id INTEGER)",
], fake.env);

assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /SQL query completed!/);
assert.match(result.stdout, /SQL Database ID\s+sql-db-123/);
assert.match(result.stdout, /"rows_read": 1/);

const invocations = loggedArgs(fake.logPath);
assert.deepEqual(flagValues(invocations[1], "--param"), []);
});

it("validates required fields and missing parameter values before invoking the Go CLI", () => {
for (const args of [
["storage-sql-query", "--sql", "SELECT 1", "--json"],
["storage-sql-query", "--id", "sql-db-123", "--json"],
["storage-sql-query", "--id", "sql-db-123", "--sql", "SELECT ?", "--param", "--json"],
]) {
const fake = setupFakeTelnyx();
const result = runCli(args, fake.env);
assert.notEqual(result.status, 0, `expected ${args.join(" ")} to fail`);
assert.ok(JSON.parse(result.stdout).error);
assert.deepEqual(loggedArgs(fake.logPath), []);
}
});

it("enforces Telnyx Go CLI v0.27.0 without changing the vendored platform pin", () => {
const fake = setupFakeTelnyx("0.26.9");
const result = runCli([
"storage-sql-query", "--id", "sql-db-123", "--sql", "SELECT 1", "--json",
], fake.env);

assert.notEqual(result.status, 0);
const error = JSON.parse(result.stdout).error as string;
assert.match(error, /0\.26\.9/);
assert.match(error, /requires >= 0\.27\.0/);
assert.deepEqual(loggedArgs(fake.logPath), [["--version"]]);
});

it("registers command help and Storage capabilities", () => {
const help = runCli(["help"]);
assert.equal(help.status, 0, help.stderr);
assert.match(help.stdout, /storage-sql-query/);
assert.match(help.stdout, /--param <value>/);
assert.match(help.stdout, /string, number, boolean, or null/);

const capabilities = runCli(["capabilities", "--json"]);
assert.equal(capabilities.status, 0, capabilities.stderr);
const response = JSON.parse(capabilities.stdout);
const commands = response.composite_commands.map((entry: { name: string }) => entry.name);
assert.ok(commands.includes("telnyx-agent storage-sql-query"));
assert.deepEqual(
response.api_capabilities["🗄️ Storage"].flatMap(
(capability: { actions: string[] }) => capability.actions,
),
["run_storage_sql_query"],
);
});
});
Loading