@getdial/cli 0.25.2 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -99,13 +99,17 @@ number
99
99
  .command("purchase")
100
100
  .description("Purchase an additional phone number. POST /api/v1/numbers.")
101
101
  .requiredOption("--inbound-instruction <text>", "system prompt for inbound calls to this number")
102
+ .requiredOption("--explicit-programmatic-consent <text>", "required attestation that the account holder consented to provisioning this number programmatically (stored on the number)")
102
103
  .option("--inbound-voice-gender <male|female>", "voice gender for inbound calls (default: female; pass male to override)")
103
- .option("--area-code <code>", "preferred US area code (only US numbers can be provisioned)")
104
+ .option("--area-code <code>", "preferred US area code (only US numbers can be provisioned; ignored with --include-imessage)")
105
+ .option("--include-imessage", "provision an iMessage number (pay-as-you-go only; provisioned asynchronously — poll `dial number list` until ready)")
104
106
  .option("--json", "machine-readable output")
105
107
  .action(async (opts) => process.exit(await runNumberPurchase({
106
108
  inboundInstruction: opts.inboundInstruction,
109
+ explicitProgrammaticConsent: opts.explicitProgrammaticConsent,
107
110
  inboundVoiceGender: opts.inboundVoiceGender,
108
111
  areaCode: opts.areaCode,
112
+ includeImessage: !!opts.includeImessage,
109
113
  json: !!opts.json,
110
114
  })));
111
115
  number
@@ -143,10 +147,11 @@ number
143
147
  });
144
148
  const message = program
145
149
  .command("message")
146
- .description("Send an SMS. POST /api/v1/messages.")
150
+ .description("Send an SMS, optionally with media (MMS). POST /api/v1/messages.")
147
151
  .option("--to <e164>", "destination phone number, E.164 (e.g. +14155551234)")
148
152
  .option("--body <text>", "message body")
149
153
  .option("--from-number-id <id>", "phoneNumberId to send from (defaults to onboard's number)")
154
+ .option("--media <path-or-url>", "media attachment: local file path (uploaded) or public http(s) URL (repeatable, max 10)", (v, prev = []) => [...prev, v], [])
150
155
  .option("--json", "machine-readable output")
151
156
  .action(async (opts) => {
152
157
  if (!opts.to || !opts.body) {
@@ -157,6 +162,7 @@ const message = program
157
162
  to: opts.to,
158
163
  body: opts.body,
159
164
  fromNumberId: opts.fromNumberId,
165
+ media: opts.media,
160
166
  json: !!opts.json,
161
167
  }));
162
168
  });
@@ -13,7 +13,8 @@ export async function runMessageList(opts) {
13
13
  return 0;
14
14
  }
15
15
  for (const m of messages) {
16
- console.log(`${m.createdAt} ${(m.direction ?? "").padEnd(8)} ${m.from} -> ${m.to} ${m.body}`);
16
+ const mediaTag = m.media && m.media.length > 0 ? ` [${m.media.length} media]` : "";
17
+ console.log(`${m.createdAt} ${(m.direction ?? "").padEnd(8)} ${m.from} -> ${m.to} ${m.body}${mediaTag}`);
17
18
  }
18
19
  return 0;
19
20
  }
@@ -3,7 +3,7 @@ import { isDialError } from "../../lib/ops/errors.js";
3
3
  import { printDialError } from "../../lib/cli-error.js";
4
4
  export async function runMessageSend(opts) {
5
5
  try {
6
- const m = await sendMessage({ to: opts.to, body: opts.body, fromNumberId: opts.fromNumberId });
6
+ const m = await sendMessage({ to: opts.to, body: opts.body, fromNumberId: opts.fromNumberId, media: opts.media });
7
7
  if (opts.json) {
8
8
  console.log(JSON.stringify({ ok: true, message: m }));
9
9
  }
@@ -13,6 +13,9 @@ export async function runMessageSend(opts) {
13
13
  console.log(` from: ${m.from}`);
14
14
  console.log(` to: ${m.to}`);
15
15
  console.log(` body: ${m.body}`);
16
+ for (const item of m.media ?? []) {
17
+ console.log(` media: ${item.url} (${item.contentType})`);
18
+ }
16
19
  console.log(` status: ${m.status}`);
17
20
  }
18
21
  return 0;
@@ -5,8 +5,10 @@ export async function runNumberPurchase(opts) {
5
5
  try {
6
6
  const n = await purchaseNumber({
7
7
  inboundInstruction: opts.inboundInstruction,
8
+ explicitProgrammaticConsent: opts.explicitProgrammaticConsent,
8
9
  inboundVoiceGender: opts.inboundVoiceGender,
9
10
  areaCode: opts.areaCode,
11
+ includeImessage: opts.includeImessage,
10
12
  });
11
13
  if (opts.json) {
12
14
  console.log(JSON.stringify({ ok: true, number: n }));
@@ -16,6 +18,11 @@ export async function runNumberPurchase(opts) {
16
18
  console.log(` number: ${n.number}`);
17
19
  console.log(` id: ${n.id}`);
18
20
  console.log(` country: ${n.country}`);
21
+ // iMessage numbers provision asynchronously: the number is returned right
22
+ // away in setupStatus "provisioning". Tell the user to poll before using it.
23
+ if (opts.includeImessage) {
24
+ console.log(` status: ${n.setupStatus ?? "provisioning"} — run \`dial number list\` until it's "ready" before sending or calling from it.`);
25
+ }
19
26
  }
20
27
  return 0;
21
28
  }
package/dist/lib/api.js CHANGED
@@ -1,6 +1,13 @@
1
- import { request } from "undici";
1
+ import { request, fetch as undiciFetch, FormData as UndiciFormData } from "undici";
2
2
  import { logger } from "./log.js";
3
+ import { VERSION } from "./version.js";
4
+ // The bundled undici only multipart-encodes its own FormData class (realm
5
+ // check) — Node's global FormData would be coerced to a text/plain string.
6
+ export { UndiciFormData as ApiFormData };
3
7
  const DEFAULT_BASE = "https://api.getdial.ai";
8
+ // Identifies the CLI on every request, so server-side request logs attribute
9
+ // provisioning (and everything else) to the client + version that made the call.
10
+ const USER_AGENT = `@getdial/cli/${VERSION}`;
4
11
  export function baseUrl() {
5
12
  return process.env.DIAL_API_URL ?? DEFAULT_BASE;
6
13
  }
@@ -13,9 +20,29 @@ export async function apiGet(path, apiKey) {
13
20
  export async function apiPatch(path, body, apiKey) {
14
21
  return apiRequest("PATCH", path, body, apiKey);
15
22
  }
23
+ function toResult(statusCode, text) {
24
+ let parsed = null;
25
+ try {
26
+ parsed = text ? JSON.parse(text) : null;
27
+ }
28
+ catch { /* keep raw */ }
29
+ if (statusCode >= 200 && statusCode < 300) {
30
+ return { ok: true, status: statusCode, data: parsed };
31
+ }
32
+ // The server's `error` field is usually a string, but validation failures
33
+ // return a structured object (Zod's flatten). Stringify those as JSON rather
34
+ // than letting them coerce to "[object Object]", so the real reason survives.
35
+ const rawError = parsed?.error;
36
+ const errMsg = typeof rawError === "string"
37
+ ? rawError
38
+ : rawError != null
39
+ ? JSON.stringify(rawError)
40
+ : text || `HTTP ${statusCode}`;
41
+ return { ok: false, status: statusCode, error: errMsg };
42
+ }
16
43
  async function apiRequest(method, path, body, apiKey, extraHeaders) {
17
44
  const url = `${baseUrl()}${path}`;
18
- const headers = { "content-type": "application/json", ...(extraHeaders ?? {}) };
45
+ const headers = { "content-type": "application/json", "user-agent": USER_AGENT, ...(extraHeaders ?? {}) };
19
46
  if (apiKey)
20
47
  headers.authorization = `Bearer ${apiKey}`;
21
48
  try {
@@ -24,25 +51,21 @@ async function apiRequest(method, path, body, apiKey, extraHeaders) {
24
51
  headers,
25
52
  body: body !== undefined ? JSON.stringify(body) : undefined,
26
53
  });
27
- const text = await res.body.text();
28
- let parsed = null;
29
- try {
30
- parsed = text ? JSON.parse(text) : null;
31
- }
32
- catch { /* keep raw */ }
33
- if (res.statusCode >= 200 && res.statusCode < 300) {
34
- return { ok: true, status: res.statusCode, data: parsed };
35
- }
36
- // The server's `error` field is usually a string, but validation failures
37
- // return a structured object (Zod's flatten). Stringify those as JSON rather
38
- // than letting them coerce to "[object Object]", so the real reason survives.
39
- const rawError = parsed?.error;
40
- const errMsg = typeof rawError === "string"
41
- ? rawError
42
- : rawError != null
43
- ? JSON.stringify(rawError)
44
- : text || `HTTP ${res.statusCode}`;
45
- return { ok: false, status: res.statusCode, error: errMsg };
54
+ return toResult(res.statusCode, await res.body.text());
55
+ }
56
+ catch (err) {
57
+ return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
58
+ }
59
+ }
60
+ /** POST a multipart/form-data body (file uploads). fetch sets the boundary header itself. */
61
+ export async function apiPostMultipart(path, form, apiKey) {
62
+ const url = `${baseUrl()}${path}`;
63
+ const headers = {};
64
+ if (apiKey)
65
+ headers.authorization = `Bearer ${apiKey}`;
66
+ try {
67
+ const res = await undiciFetch(url, { method: "POST", headers, body: form });
68
+ return toResult(res.status, await res.text());
46
69
  }
47
70
  catch (err) {
48
71
  return { ok: false, status: 0, error: err instanceof Error ? err.message : String(err) };
@@ -1,13 +1,81 @@
1
- import { apiGet, apiPost } from "../api.js";
1
+ import { readFileSync } from "node:fs";
2
+ import { basename, extname } from "node:path";
3
+ import { apiGet, apiPost, apiPostMultipart, ApiFormData } from "../api.js";
2
4
  import { requireAuth, requireFromNumberId } from "./auth.js";
3
5
  import { DialError } from "./errors.js";
6
+ export const MAX_MEDIA_ITEMS = 10;
7
+ // File extensions the API accepts for uploads, mapped to their MIME type
8
+ // (mirrors the server's supported-content-type list).
9
+ const EXT_CONTENT_TYPE = {
10
+ jpg: "image/jpeg",
11
+ jpeg: "image/jpeg",
12
+ png: "image/png",
13
+ gif: "image/gif",
14
+ webp: "image/webp",
15
+ bmp: "image/bmp",
16
+ mp3: "audio/mpeg",
17
+ m4a: "audio/mp4",
18
+ ogg: "audio/ogg",
19
+ wav: "audio/wav",
20
+ amr: "audio/amr",
21
+ mp4: "video/mp4",
22
+ "3gp": "video/3gpp",
23
+ pdf: "application/pdf",
24
+ vcf: "text/vcard",
25
+ ics: "text/calendar",
26
+ };
27
+ function isHttpUrl(value) {
28
+ return /^https?:\/\//i.test(value);
29
+ }
30
+ /** Read a local media file and resolve its MIME type from the extension. */
31
+ function readMediaFile(path) {
32
+ const ext = extname(path).slice(1).toLowerCase();
33
+ const contentType = EXT_CONTENT_TYPE[ext];
34
+ if (!contentType) {
35
+ const supported = Object.keys(EXT_CONTENT_TYPE).join(", ");
36
+ throw new DialError("unsupported_media", `unsupported media file extension ".${ext}" (${path}). Supported: ${supported}`);
37
+ }
38
+ let data;
39
+ try {
40
+ data = readFileSync(path);
41
+ }
42
+ catch (err) {
43
+ throw new DialError("media_read_failed", `could not read media file ${path}: ${err instanceof Error ? err.message : String(err)}`);
44
+ }
45
+ return { data, contentType, name: basename(path) };
46
+ }
4
47
  export async function sendMessage(opts) {
5
48
  const auth = requireAuth();
6
49
  const fromNumberId = requireFromNumberId(auth, opts.fromNumberId);
50
+ const media = opts.media ?? [];
51
+ if (media.length > MAX_MEDIA_ITEMS) {
52
+ throw new DialError("too_much_media", `at most ${MAX_MEDIA_ITEMS} media items are allowed per message (got ${media.length})`);
53
+ }
7
54
  // No `channel`: the server determines it from the from-number (a standard number
8
55
  // sends SMS; an iMessage number sends iMessage with RCS/SMS fallback) and its send
9
56
  // schema is strict — sending a stale `channel` field is rejected as a 400.
10
- const res = await apiPost("/api/v1/messages", { to: opts.to, body: opts.body, fromNumberId }, auth.apiKey);
57
+ // URLs-only goes as plain JSON; any local file switches to multipart.
58
+ const hasFiles = media.some((m) => !isHttpUrl(m));
59
+ let res;
60
+ if (!hasFiles) {
61
+ res = await apiPost("/api/v1/messages", { to: opts.to, body: opts.body, fromNumberId, ...(media.length ? { mediaUrls: media } : {}) }, auth.apiKey);
62
+ }
63
+ else {
64
+ const form = new ApiFormData();
65
+ form.set("to", opts.to);
66
+ form.set("body", opts.body);
67
+ form.set("fromNumberId", fromNumberId);
68
+ for (const item of media) {
69
+ if (isHttpUrl(item)) {
70
+ form.append("mediaUrls", item);
71
+ }
72
+ else {
73
+ const file = readMediaFile(item);
74
+ form.append("media", new Blob([new Uint8Array(file.data)], { type: file.contentType }), file.name);
75
+ }
76
+ }
77
+ res = await apiPostMultipart("/api/v1/messages", form, auth.apiKey);
78
+ }
11
79
  if (!res.ok)
12
80
  throw new DialError("send_failed", res.error, res.status);
13
81
  return res.data.message;
@@ -10,10 +10,16 @@ export async function listNumbers() {
10
10
  }
11
11
  export async function purchaseNumber(opts) {
12
12
  const auth = requireAuth();
13
- const body = { inboundInstruction: opts.inboundInstruction };
13
+ const body = {
14
+ inboundInstruction: opts.inboundInstruction,
15
+ explicitProgrammaticConsent: opts.explicitProgrammaticConsent,
16
+ };
14
17
  if (opts.inboundVoiceGender)
15
18
  body.inboundVoiceGender = opts.inboundVoiceGender;
16
- if (opts.areaCode)
19
+ // iMessage numbers ignore areaCode, so only send it for standard numbers.
20
+ if (opts.includeImessage)
21
+ body.capabilities = ["sms", "call", "imessage"];
22
+ else if (opts.areaCode)
17
23
  body.areaCode = opts.areaCode;
18
24
  const res = await apiPost("/api/v1/numbers", body, auth.apiKey);
19
25
  if (!res.ok)
@@ -4,8 +4,10 @@ import { purchaseNumber } from "../../lib/ops/numbers.js";
4
4
  import { phoneNumberSchema } from "../schemas.js";
5
5
  const inputSchema = {
6
6
  inboundInstruction: z.string().min(1).describe("System prompt for inbound calls to this number"),
7
+ explicitProgrammaticConsent: z.string().min(1).max(2000).describe("Required attestation (max 2000 chars) that the account holder consented to provisioning this number programmatically; stored on the number"),
7
8
  inboundVoiceGender: z.enum(["male", "female"]).optional().describe("Voice gender for inbound calls to this number; the default is female"),
8
- areaCode: z.string().optional().describe("Preferred US area code; omitted → any available US number. Only US numbers can be provisioned at this time"),
9
+ areaCode: z.string().optional().describe("Preferred US area code; omitted → any available US number. Only US numbers can be provisioned at this time. Ignored for iMessage numbers"),
10
+ includeImessage: z.boolean().optional().describe('Provision an iMessage number (pay-as-you-go only; provisioned asynchronously — poll List Numbers until setupStatus is "ready")'),
9
11
  };
10
12
  export const purchaseNumberTool = {
11
13
  name: "purchase_number",
@@ -19,8 +21,10 @@ export const purchaseNumberTool = {
19
21
  run: async (args) => jsonResult({
20
22
  number: await purchaseNumber({
21
23
  inboundInstruction: args.inboundInstruction,
24
+ explicitProgrammaticConsent: args.explicitProgrammaticConsent,
22
25
  inboundVoiceGender: args.inboundVoiceGender,
23
26
  areaCode: args.areaCode,
27
+ includeImessage: args.includeImessage,
24
28
  }),
25
29
  }),
26
30
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getdial/cli",
3
- "version": "0.25.2",
3
+ "version": "0.27.0",
4
4
  "description": "Dial CLI — install, sign up, and run the local listen service.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/skills.tar.gz CHANGED
Binary file