carlyemail 0.1.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +113 -0
  3. package/carlyemail.js +497 -0
  4. package/package.json +27 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SWH Labs LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # carlyemail
2
+
3
+ Real email inboxes your agent can send, receive and reply from.
4
+
5
+ ```bash
6
+ npx carlyemail signup --human-email you@example.com --username my-agent
7
+ ```
8
+
9
+ That returns a working address and an API key. Confirm the emailed code and it
10
+ can send:
11
+
12
+ ```bash
13
+ npx carlyemail verify 123456
14
+ npx carlyemail send \
15
+ --from my-agent@agents.carlyemail.com \
16
+ --to someone@example.com \
17
+ --subject "Hello" \
18
+ --text "Sent by an agent with its own address."
19
+ ```
20
+
21
+ Install it properly if you use it often:
22
+
23
+ ```bash
24
+ npm install -g carlyemail
25
+ ```
26
+
27
+ ## Commands
28
+
29
+ | | |
30
+ |---|---|
31
+ | `signup --human-email <e> --username <u>` | Create an account and an inbox |
32
+ | `verify <code>` | Confirm the owner email |
33
+ | `whoami` | Identity and scope of the current key |
34
+ | `inboxes` | List inboxes |
35
+ | `create --username <u>` | Create an inbox |
36
+ | `delete <inbox> --yes` | Delete an inbox and its mail |
37
+ | `send --from <i> --to <a> --subject <s> --text <t>` | Send an email |
38
+ | `messages <inbox>` | List messages |
39
+ | `plan` | Current plan, with usage against every limit |
40
+ | `upgrade <plan>` | A Stripe checkout link |
41
+ | `billing` | Invoices, card changes, cancellation |
42
+ | `mcp` | The MCP endpoint, for Claude and other clients |
43
+
44
+ ## Scripting
45
+
46
+ `--json` prints the API response untouched — the same shape the
47
+ [API reference](https://docs.carlyemail.com/api-reference) documents, not a
48
+ reshaped subset:
49
+
50
+ ```bash
51
+ carlyemail inboxes --json | jq -r '.inboxes[].email'
52
+ carlyemail plan --json | jq '.organization.storage_bytes'
53
+ ```
54
+
55
+ Failures still exit `1` under `--json`, and the error is still printed, so
56
+ `set -e` behaves.
57
+
58
+ ## Configuration
59
+
60
+ The key is saved to `~/.carlyemail/config.json`, written `0600` — it can read
61
+ and send your mail, so it is not left group-readable.
62
+
63
+ Two environment variables override it, and the environment always wins:
64
+
65
+ | | |
66
+ |---|---|
67
+ | `CARLYEMAIL_API_KEY` | Use this key instead of the saved one |
68
+ | `CARLYEMAIL_API_URL` | Point at a different deployment |
69
+
70
+ That ordering is what makes CI and containers work without touching the saved
71
+ state, and it means running one command as a different account is a prefix
72
+ rather than a login.
73
+
74
+ ## Notes
75
+
76
+ **Sign-up is idempotent by owner email, and rotates the key.** Running it again
77
+ for an address that already has an account issues a new key and **revokes the
78
+ previous one**. It is how you recover a lost key; it is not a way to add a
79
+ second inbox. Use `create` for that.
80
+
81
+ **`delete` requires `--yes`.** It removes the inbox and every message in it,
82
+ and there is no undo.
83
+
84
+ **Exit codes are meaningful.** `0` on success, `1` on any failure, so
85
+ `carlyemail send … || handle-it` behaves in a script.
86
+
87
+ **Errors carry their fix.** The API answers with a message, the thing that
88
+ clears it, and a documentation link; all three are printed.
89
+
90
+ ```
91
+ ✗ Inbox limit reached (3 on the free plan).
92
+ Upgrade the plan, or delete an inbox you no longer need.
93
+ https://docs.carlyemail.com/inboxes
94
+ ```
95
+
96
+ ## Requirements
97
+
98
+ Node 18 or newer. No dependencies — this is one file that uses Node's built-in
99
+ `fetch`, so `npx` never resolves a tree and never fails for a reason unrelated
100
+ to the thing you asked for.
101
+
102
+ ## Licence
103
+
104
+ MIT — see `LICENSE`. That covers **this CLI only**. The CarlyEmail service it
105
+ talks to is a separate, proprietary product; a permissive licence on an HTTP
106
+ client grants nothing over the API behind it.
107
+
108
+ ## Links
109
+
110
+ - [Documentation](https://docs.carlyemail.com)
111
+ - [API reference](https://docs.carlyemail.com/api-reference)
112
+ - [Pricing](https://carlyemail.com/pricing)
113
+ - [Support](https://docs.carlyemail.com/support)
package/carlyemail.js ADDED
@@ -0,0 +1,497 @@
1
+ #!/usr/bin/env node
2
+ // CarlyEmail command line.
3
+ //
4
+ // One file, no dependencies, no build step. That is a deliberate constraint
5
+ // rather than minimalism for its own sake: this is the first thing a new user
6
+ // runs, usually through `npx`, and every dependency is a chance for that first
7
+ // command to fail on somebody's machine for a reason that has nothing to do
8
+ // with us. Node 18+ has `fetch` built in, which is the only thing a REST client
9
+ // actually needs.
10
+ //
11
+ // The CLI is a thin shell over the same public API the docs describe. It holds
12
+ // no logic the API does not — if a command here disagrees with the server, the
13
+ // server is right.
14
+
15
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, realpathSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ export const VERSION = "0.1.0";
21
+
22
+ const DEFAULT_API = "https://api.carlyemail.com";
23
+ const CONFIG_DIR = join(homedir(), ".carlyemail");
24
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
25
+
26
+ // ---------------------------------------------------------------- output
27
+
28
+ const isTTY = process.stdout.isTTY;
29
+ const paint = (code, s) => (isTTY ? `[${code}m${s}` : s);
30
+ const dim = (s) => paint(2, s);
31
+ const bold = (s) => paint(1, s);
32
+
33
+ export const ok = (s) => `${paint(32, "✓")} ${s}`;
34
+ export const arrow = (s) => `${dim("→")} ${s}`;
35
+
36
+ /** A credential is shown once, at creation, and never echoed again. */
37
+ export function maskKey(key) {
38
+ if (!key || key.length < 12) return "(hidden)";
39
+ return `${key.slice(0, 9)}${"·".repeat(8)}${key.slice(-4)}`;
40
+ }
41
+
42
+ // ---------------------------------------------------------------- config
43
+
44
+ /**
45
+ * Where the API key lives between commands.
46
+ *
47
+ * The environment wins over the file, always. That is what lets CI, a
48
+ * container, or a second account run without touching the machine's saved
49
+ * state — and it means `CARLYEMAIL_API_KEY=... carlyemail whoami` does the
50
+ * obvious thing rather than silently using somebody else's key.
51
+ */
52
+ export function loadConfig(env = process.env, file = CONFIG_FILE) {
53
+ const config = { api_url: DEFAULT_API };
54
+ if (existsSync(file)) {
55
+ try {
56
+ Object.assign(config, JSON.parse(readFileSync(file, "utf8")));
57
+ } catch {
58
+ // A corrupt config must not brick every command. `signup` rewrites it.
59
+ process.stderr.write(dim(`warning: ${file} is not valid JSON; ignoring it\n`));
60
+ }
61
+ }
62
+ if (env.CARLYEMAIL_API_KEY) config.api_key = env.CARLYEMAIL_API_KEY;
63
+ if (env.CARLYEMAIL_API_URL) config.api_url = env.CARLYEMAIL_API_URL;
64
+ return config;
65
+ }
66
+
67
+ /**
68
+ * Written 0600, and the directory 0700.
69
+ *
70
+ * This file holds a credential that can read and send someone's mail. The
71
+ * default umask would leave it group- and world-readable on a shared machine,
72
+ * which is not a state anybody would choose if asked.
73
+ */
74
+ export function saveConfig(config, file = CONFIG_FILE, dir = CONFIG_DIR) {
75
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
76
+ writeFileSync(file, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
77
+ chmodSync(file, 0o600); // existing files keep their old mode without this
78
+ return file;
79
+ }
80
+
81
+ // ---------------------------------------------------------------- errors
82
+
83
+ export class ApiError extends Error {
84
+ constructor(status, body) {
85
+ const message = (body && body.message) || `request failed with ${status}`;
86
+ super(message);
87
+ this.status = status;
88
+ this.code = body && body.code;
89
+ this.fix = body && body.fix;
90
+ this.docs = body && body.docs;
91
+ }
92
+
93
+ /**
94
+ * The API answers with `{name, code, message, fix, docs}`. The `fix` field
95
+ * is the actionable half and printing only `message` throws it away — which
96
+ * matters most for the plan limits, where the message says what ran out and
97
+ * the fix says what clears it.
98
+ */
99
+ render() {
100
+ const lines = [`${paint(31, "✗")} ${this.message}`];
101
+ if (this.fix) lines.push(` ${this.fix}`);
102
+ if (this.docs) lines.push(dim(` ${this.docs}`));
103
+ return lines.join("\n");
104
+ }
105
+ }
106
+
107
+ class UsageError extends Error {}
108
+
109
+ // ---------------------------------------------------------------- transport
110
+
111
+ export async function request(config, method, path, { body, auth = true, fetchImpl = fetch } = {}) {
112
+ const headers = { accept: "application/json" };
113
+ if (body !== undefined) headers["content-type"] = "application/json";
114
+ if (auth) {
115
+ if (!config.api_key) {
116
+ throw new UsageError(
117
+ "No API key. Run `carlyemail signup` first, or set CARLYEMAIL_API_KEY."
118
+ );
119
+ }
120
+ headers.authorization = `Bearer ${config.api_key}`;
121
+ }
122
+
123
+ let response;
124
+ try {
125
+ response = await fetchImpl(`${config.api_url}${path}`, {
126
+ method,
127
+ headers,
128
+ body: body === undefined ? undefined : JSON.stringify(body),
129
+ });
130
+ } catch (cause) {
131
+ throw new Error(`could not reach ${config.api_url}: ${cause.message}`);
132
+ }
133
+
134
+ if (response.status === 204) return null;
135
+
136
+ const text = await response.text();
137
+ let parsed = null;
138
+ if (text) {
139
+ try {
140
+ parsed = JSON.parse(text);
141
+ } catch {
142
+ // A gateway or proxy failing in front of us answers HTML, not our
143
+ // envelope. Say so rather than printing a page of markup.
144
+ if (!response.ok) throw new ApiError(response.status, { message: text.slice(0, 200) });
145
+ }
146
+ }
147
+ if (!response.ok) throw new ApiError(response.status, parsed);
148
+ return parsed;
149
+ }
150
+
151
+ // ---------------------------------------------------------------- arguments
152
+
153
+ /**
154
+ * `--flag value`, `--flag=value`, and bare positionals. No parser library,
155
+ * because the grammar is this small and a dependency here costs more than it
156
+ * saves.
157
+ */
158
+ export function parseArgs(argv) {
159
+ const flags = {};
160
+ const positional = [];
161
+ for (let i = 0; i < argv.length; i++) {
162
+ const arg = argv[i];
163
+ if (!arg.startsWith("--")) {
164
+ positional.push(arg);
165
+ continue;
166
+ }
167
+ const equals = arg.indexOf("=");
168
+ if (equals !== -1) {
169
+ flags[arg.slice(2, equals)] = arg.slice(equals + 1);
170
+ } else if (i + 1 < argv.length && !argv[i + 1].startsWith("--")) {
171
+ flags[arg.slice(2)] = argv[++i];
172
+ } else {
173
+ flags[arg.slice(2)] = true;
174
+ }
175
+ }
176
+ return { flags, positional };
177
+ }
178
+
179
+ function required(flags, name) {
180
+ const value = flags[name];
181
+ if (typeof value !== "string" || !value) {
182
+ throw new UsageError(`--${name} is required`);
183
+ }
184
+ return value;
185
+ }
186
+
187
+ // ---------------------------------------------------------------- commands
188
+
189
+ const commands = {};
190
+ const define = (name, summary, usage, run) => {
191
+ commands[name] = { name, summary, usage, run };
192
+ };
193
+
194
+ /**
195
+ * Print either the raw API payload or a human rendering of it.
196
+ *
197
+ * `--json` exists because this is a CLI for people building agents, and the
198
+ * second thing they do after running a command by hand is put it in a script.
199
+ * Making them re-parse a table we formatted would be a strange thing to ask
200
+ * when we already had the JSON.
201
+ *
202
+ * The payload is passed through untouched — not a reshaped subset — so what a
203
+ * script sees matches the API reference rather than our idea of the useful
204
+ * fields.
205
+ */
206
+ function emit(ctx, payload, render) {
207
+ if (ctx.flags.json) {
208
+ ctx.print(JSON.stringify(payload, null, 2));
209
+ return;
210
+ }
211
+ render();
212
+ }
213
+
214
+ define(
215
+ "signup",
216
+ "Create an account and an inbox",
217
+ "carlyemail signup --human-email you@example.com --username my-agent",
218
+ async (ctx) => {
219
+ const human_email = required(ctx.flags, "human-email");
220
+ const username = required(ctx.flags, "username");
221
+
222
+ const out = await request(ctx.config, "POST", "/v0/agent/sign-up", {
223
+ auth: false,
224
+ body: { human_email, username, source: "cli" },
225
+ });
226
+
227
+ // Saved before anything else is printed: the key is returned exactly once
228
+ // and is unrecoverable afterwards, so losing it to a later crash would
229
+ // cost the account.
230
+ const saved = saveConfig(
231
+ { ...ctx.config, api_key: out.api_key, organization_id: out.organization_id },
232
+ ctx.configFile,
233
+ ctx.configDir
234
+ );
235
+
236
+ ctx.print(ok(bold(out.inbox_id)));
237
+ ctx.print(ok(`key saved to ${saved}`));
238
+ ctx.print("");
239
+ ctx.print(arrow(`check ${human_email} for a 6-digit code, then:`));
240
+ ctx.print(` carlyemail verify <code>`);
241
+ ctx.print("");
242
+ ctx.print(dim("Until it is confirmed the account can read its own mail but not send."));
243
+ }
244
+ );
245
+
246
+ define("verify", "Confirm the owner email with the code", "carlyemail verify 123456", async (ctx) => {
247
+ const code = ctx.positional[0] || ctx.flags.code;
248
+ if (!code) throw new UsageError("the 6-digit code is required: carlyemail verify 123456");
249
+
250
+ await request(ctx.config, "POST", "/v0/agent/verify", { body: { otp_code: String(code) } });
251
+ ctx.print(ok("verified — this account can send now"));
252
+ });
253
+
254
+ define("whoami", "Show the key's identity and scope", "carlyemail whoami", async (ctx) => {
255
+ const me = await request(ctx.config, "GET", "/v0/auth/me");
256
+ emit(ctx, me, () => {
257
+ ctx.print(`${dim("organization")} ${me.organization_id ?? "—"}`);
258
+ ctx.print(`${dim("scope")} ${me.scope_type ?? "—"}${me.scope_id ? dim(` ${me.scope_id}`) : ""}`);
259
+ // Worth its own line rather than folded into scope: a key pinned to one
260
+ // inbox cannot reach a sibling, and "why does this 404" is the question it
261
+ // answers.
262
+ if (me.inbox_id) ctx.print(`${dim("inbox")} ${me.inbox_id} ${dim("(this key sees only this inbox)")}`);
263
+ ctx.print(`${dim("key")} ${maskKey(ctx.config.api_key)} ${dim(me.api_key_id ?? "")}`);
264
+ ctx.print(`${dim("api")} ${ctx.config.api_url}`);
265
+ });
266
+ });
267
+
268
+ define("inboxes", "List inboxes", "carlyemail inboxes", async (ctx) => {
269
+ const out = await request(ctx.config, "GET", "/v0/inboxes");
270
+ emit(ctx, out, () => {
271
+ if (!out.inboxes.length) {
272
+ ctx.print(dim("no inboxes"));
273
+ return;
274
+ }
275
+ for (const inbox of out.inboxes) {
276
+ ctx.print(`${bold(inbox.email)}${inbox.display_name ? dim(` ${inbox.display_name}`) : ""}`);
277
+ }
278
+ });
279
+ });
280
+
281
+ define("create", "Create an inbox", "carlyemail create --username support", async (ctx) => {
282
+ const username = ctx.positional[0] || required(ctx.flags, "username");
283
+ const body = { username };
284
+ if (typeof ctx.flags.domain === "string") body.domain = ctx.flags.domain;
285
+ const inbox = await request(ctx.config, "POST", "/v0/inboxes", { body });
286
+ emit(ctx, inbox, () => ctx.print(ok(bold(inbox.email))));
287
+ });
288
+
289
+ define("delete", "Delete an inbox and its mail", "carlyemail delete support@carlyemail.com", async (ctx) => {
290
+ const inbox = ctx.positional[0];
291
+ if (!inbox) throw new UsageError("which inbox? carlyemail delete <inbox>");
292
+ if (!ctx.flags.yes) {
293
+ throw new UsageError(
294
+ `this deletes ${inbox} and every message in it. Re-run with --yes to confirm.`
295
+ );
296
+ }
297
+ await request(ctx.config, "DELETE", `/v0/inboxes/${encodeURIComponent(inbox)}`);
298
+ ctx.print(ok(`deleted ${inbox}`));
299
+ });
300
+
301
+ define(
302
+ "send",
303
+ "Send an email",
304
+ 'carlyemail send --from me@carlyemail.com --to you@example.com --subject Hi --text "Hello"',
305
+ async (ctx) => {
306
+ const from = required(ctx.flags, "from");
307
+ const body = {
308
+ to: required(ctx.flags, "to").split(",").map((s) => s.trim()),
309
+ subject: typeof ctx.flags.subject === "string" ? ctx.flags.subject : undefined,
310
+ text: typeof ctx.flags.text === "string" ? ctx.flags.text : undefined,
311
+ html: typeof ctx.flags.html === "string" ? ctx.flags.html : undefined,
312
+ };
313
+ const sent = await request(
314
+ ctx.config,
315
+ "POST",
316
+ `/v0/inboxes/${encodeURIComponent(from)}/messages/send`,
317
+ { body }
318
+ );
319
+ emit(ctx, sent, () => ctx.print(ok(`sent ${dim(sent.message_id ?? "")}`)));
320
+ }
321
+ );
322
+
323
+ define("messages", "List messages in an inbox", "carlyemail messages me@carlyemail.com", async (ctx) => {
324
+ const inbox = ctx.positional[0];
325
+ if (!inbox) throw new UsageError("which inbox? carlyemail messages <inbox>");
326
+ const out = await request(
327
+ ctx.config,
328
+ "GET",
329
+ `/v0/inboxes/${encodeURIComponent(inbox)}/messages`
330
+ );
331
+ emit(ctx, out, () => {
332
+ if (!out.messages.length) {
333
+ ctx.print(dim("no messages"));
334
+ return;
335
+ }
336
+ for (const m of out.messages) {
337
+ const when = (m.timestamp || "").slice(0, 16).replace("T", " ");
338
+ ctx.print(`${dim(when)} ${bold(m.subject || "(no subject)")}`);
339
+ ctx.print(`${" ".repeat(18)}${dim(`from ${m.from ?? m.from_address ?? "?"}`)}`);
340
+ }
341
+ });
342
+ });
343
+
344
+ define("plan", "Show the current plan and its limits", "carlyemail plan", async (ctx) => {
345
+ const [billing, org] = await Promise.all([
346
+ request(ctx.config, "GET", "/v0/billing"),
347
+ request(ctx.config, "GET", "/v0/organizations"),
348
+ ]);
349
+ if (ctx.flags.json) {
350
+ ctx.print(JSON.stringify({ billing, organization: org }, null, 2));
351
+ return;
352
+ }
353
+ const cap = (n) => (n === null || n === undefined ? "unlimited" : String(n));
354
+ const gb = (b) => (b === null || b === undefined ? "unlimited" : `${(b / 1024 ** 3).toFixed(1)} GB`);
355
+
356
+ ctx.print(`${bold(billing.plan)} ${dim(`$${billing.price_usd_month}/mo`)}`);
357
+ ctx.print("");
358
+ ctx.print(` ${dim("inboxes")} ${org.inbox_count} / ${cap(org.inbox_limit)}`);
359
+ ctx.print(` ${dim("domains")} ${org.domain_count} / ${cap(org.domain_limit)}`);
360
+ ctx.print(` ${dim("pods")} ${org.pod_count} / ${cap(org.pod_limit)}`);
361
+ ctx.print(` ${dim("webhooks")} ${org.webhook_count} / ${cap(org.webhook_limit)}`);
362
+ ctx.print(` ${dim("storage")} ${gb(org.storage_bytes)} / ${gb(org.storage_limit_bytes)}`);
363
+ ctx.print(` ${dim("email")} ${cap(billing.monthly_emails)} a month`);
364
+ });
365
+
366
+ define("upgrade", "Get a checkout link for a paid plan", "carlyemail upgrade developer", async (ctx) => {
367
+ const plan = ctx.positional[0] || ctx.flags.plan;
368
+ if (!plan) throw new UsageError("which plan? carlyemail upgrade developer|startup");
369
+ const out = await request(ctx.config, "POST", "/v0/billing/checkout", { body: { plan } });
370
+ emit(ctx, out, () => {
371
+ ctx.print(arrow("open this to upgrade:"));
372
+ ctx.print(out.checkout_url);
373
+ });
374
+ });
375
+
376
+ define("billing", "Open the billing portal (invoices, card, cancel)", "carlyemail billing", async (ctx) => {
377
+ const out = await request(ctx.config, "POST", "/v0/billing/portal");
378
+ emit(ctx, out, () => {
379
+ ctx.print(arrow("invoices, card changes and cancellation:"));
380
+ ctx.print(out.portal_url);
381
+ });
382
+ });
383
+
384
+ define("mcp", "Print the MCP endpoint for Claude and other clients", "carlyemail mcp", (ctx) => {
385
+ const endpoint = `${ctx.config.api_url}/mcp`;
386
+ ctx.print(bold(endpoint));
387
+ ctx.print("");
388
+ ctx.print("Add it as a custom connector. It speaks OAuth with dynamic client");
389
+ ctx.print("registration, so there is no client_id to configure.");
390
+ ctx.print("");
391
+ ctx.print(dim("An API key works too: Authorization: Bearer <key>"));
392
+ });
393
+
394
+ // ---------------------------------------------------------------- help
395
+
396
+ export function helpText() {
397
+ const width = Math.max(...Object.keys(commands).map((n) => n.length));
398
+ const lines = [
399
+ bold("carlyemail") + dim(` ${VERSION}`),
400
+ "",
401
+ "Real email inboxes your agent can send, receive and reply from.",
402
+ "",
403
+ bold("Commands"),
404
+ ];
405
+ for (const c of Object.values(commands)) {
406
+ lines.push(` ${c.name.padEnd(width + 2)}${c.summary}`);
407
+ }
408
+ lines.push(
409
+ "",
410
+ bold("Getting started"),
411
+ " carlyemail signup --human-email you@example.com --username my-agent",
412
+ " carlyemail verify 123456",
413
+ " carlyemail send --from my-agent@agents.carlyemail.com \\",
414
+ ' --to someone@example.com --subject Hi --text "Hello from an agent"',
415
+ "",
416
+ bold("Options"),
417
+ " --json" + dim(" print the raw API response instead of a table"),
418
+ "",
419
+ bold("Configuration"),
420
+ ` ${CONFIG_FILE}` + dim(" (written 0600)"),
421
+ " CARLYEMAIL_API_KEY" + dim(" overrides the saved key"),
422
+ " CARLYEMAIL_API_URL" + dim(" overrides the API base URL"),
423
+ "",
424
+ dim(" https://docs.carlyemail.com")
425
+ );
426
+ return lines.join("\n");
427
+ }
428
+
429
+ // ---------------------------------------------------------------- entry
430
+
431
+ export async function main(argv, { env = process.env, print = console.log, configFile, configDir } = {}) {
432
+ const { flags, positional } = parseArgs(argv);
433
+ const name = positional.shift();
434
+
435
+ if (!name || name === "help" || flags.help) {
436
+ print(helpText());
437
+ return 0;
438
+ }
439
+ if (name === "version" || flags.version) {
440
+ print(VERSION);
441
+ return 0;
442
+ }
443
+
444
+ const command = commands[name];
445
+ if (!command) {
446
+ // Suggest rather than just refuse: a wrong guess at a command name is the
447
+ // most common thing a first-time user does.
448
+ const near = Object.keys(commands).filter((c) => c.startsWith(name[0]));
449
+ print(`${paint(31, "✗")} unknown command "${name}"`);
450
+ if (near.length) print(dim(` did you mean: ${near.join(", ")}?`));
451
+ print(dim(" carlyemail help"));
452
+ return 1;
453
+ }
454
+
455
+ const config = loadConfig(env, configFile);
456
+ try {
457
+ await command.run({ config, flags, positional, print, configFile, configDir });
458
+ return 0;
459
+ } catch (error) {
460
+ if (error instanceof ApiError) {
461
+ print(error.render());
462
+ } else if (error instanceof UsageError) {
463
+ print(`${paint(31, "✗")} ${error.message}`);
464
+ print(dim(` ${command.usage}`));
465
+ } else {
466
+ print(`${paint(31, "✗")} ${error.message}`);
467
+ }
468
+ return 1;
469
+ }
470
+ }
471
+
472
+ /**
473
+ * Whether this file was run as a program rather than imported.
474
+ *
475
+ * The obvious version — comparing `import.meta.url` to
476
+ * `file://${process.argv[1]}` — is wrong in exactly the case that matters.
477
+ * `npm install` puts a **symlink** in `node_modules/.bin`, so `argv[1]` is the
478
+ * link and `import.meta.url` is its target; the strings never match and the
479
+ * CLI exits 0 having done nothing. It works perfectly from a checkout and is
480
+ * silently inert once published, which is the worst shape a bug can take.
481
+ *
482
+ * `realpathSync` resolves the link. `fileURLToPath` is used rather than
483
+ * trimming `file://` by hand because that is also wrong on Windows, where the
484
+ * URL carries a drive letter.
485
+ */
486
+ function invokedDirectly() {
487
+ if (!process.argv[1]) return false;
488
+ try {
489
+ return realpathSync(process.argv[1]) === fileURLToPath(import.meta.url);
490
+ } catch {
491
+ return false; // argv[1] is not a path we can resolve — treat as imported
492
+ }
493
+ }
494
+
495
+ if (invokedDirectly()) {
496
+ main(process.argv.slice(2)).then((code) => process.exit(code));
497
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "carlyemail",
3
+ "version": "0.1.0",
4
+ "description": "Real email inboxes your agent can send, receive and reply from.",
5
+ "keywords": ["email", "agent", "ai", "inbox", "smtp", "mcp", "cli"],
6
+ "homepage": "https://carlyemail.com",
7
+ "bugs": "https://docs.carlyemail.com/support",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/shirschfield/carlyemail.git",
11
+ "directory": "cli"
12
+ },
13
+ "license": "MIT",
14
+ "author": "SWH Labs LLC",
15
+ "type": "module",
16
+ "bin": {
17
+ "carlyemail": "./carlyemail.js"
18
+ },
19
+ "exports": "./carlyemail.js",
20
+ "files": ["carlyemail.js", "README.md", "LICENSE"],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "scripts": {
25
+ "test": "node --test"
26
+ }
27
+ }