From 0c096f6c9f445b5a2b9b3cdb3437e88991c9b6a2 Mon Sep 17 00:00:00 2001 From: ty <786220806@qq.com> Date: Sun, 22 Mar 2026 14:29:47 +0800 Subject: [PATCH] docs: add MoeMail CLI implementation plan 15 tasks covering scaffolding, core modules, all 7 commands, server-side send auth fix, README, llms.txt, and CI/CD workflow. Co-Authored-By: Claude Opus 4.6 (1M context) --- specs/2026-03-22-moemail-cli-plan.md | 1143 ++++++++++++++++++++++++++ 1 file changed, 1143 insertions(+) create mode 100644 specs/2026-03-22-moemail-cli-plan.md diff --git a/specs/2026-03-22-moemail-cli-plan.md b/specs/2026-03-22-moemail-cli-plan.md new file mode 100644 index 0000000..ec832ab --- /dev/null +++ b/specs/2026-03-22-moemail-cli-plan.md @@ -0,0 +1,1143 @@ +# MoeMail CLI Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build an agent-first CLI tool that wraps MoeMail's existing REST API, published as an npm package. + +**Architecture:** CLI lives in `packages/cli/`, uses commander for arg parsing, calls MoeMail API via `fetch` with `X-API-Key` auth. One server-side fix needed for the send endpoint auth. + +**Tech Stack:** TypeScript, Bun (build), commander (CLI framework), Node built-in `fetch` and `fs` + +**Spec:** `specs/2026-03-22-moemail-cli-design.md` + +--- + +## File Structure + +``` +packages/cli/ +├── src/ +│ ├── index.ts # Entry point — program definition, command registration +│ ├── commands/ +│ │ ├── config.ts # config set/list subcommands +│ │ ├── create.ts # create temp email +│ │ ├── list.ts # list mailboxes or messages +│ │ ├── wait.ts # poll for new messages +│ │ ├── read.ts # read message content +│ │ ├── send.ts # send email +│ │ └── delete.ts # delete mailbox or message +│ ├── api.ts # HTTP client — all API calls, error handling, auth header +│ ├── config.ts # Config file read/write (~/.moemail/config.json) + env override +│ └── output.ts # Output helpers — json/text formatting, stderr logging +├── package.json +├── tsconfig.json +└── README.md +``` + +**Server-side change:** +- Modify: `app/api/emails/[id]/send/route.ts` — switch from `auth()` to `getUserId()` + +**Agent discoverability:** +- Create: `public/llms.txt` + +**CI/CD:** +- Create: `.github/workflows/publish-cli.yml` + +--- + +### Task 1: Project Scaffolding + +**Files:** +- Create: `packages/cli/package.json` +- Create: `packages/cli/tsconfig.json` +- Create: `packages/cli/src/index.ts` + +- [ ] **Step 1: Create package.json** + +```json +{ + "name": "moemail-cli", + "version": "0.1.0", + "description": "Agent-first CLI for MoeMail temporary email service", + "type": "module", + "bin": { + "moemail": "dist/index.js" + }, + "scripts": { + "build": "bun build ./src/index.ts --outdir ./dist --target=node", + "dev": "bun run ./src/index.ts" + }, + "files": ["dist"], + "keywords": ["email", "temporary", "cli", "agent", "ai"], + "license": "MIT", + "dependencies": { + "commander": "^12.0.0" + } +} +``` + +- [ ] **Step 2: Create tsconfig.json** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "skipLibCheck": true + }, + "include": ["src"] +} +``` + +- [ ] **Step 3: Create minimal entry point** + +`packages/cli/src/index.ts`: +```ts +#!/usr/bin/env node +import { Command } from "commander"; + +const program = new Command(); + +program + .name("moemail") + .description("MoeMail CLI — Agent-friendly temporary email tool") + .version("0.1.0"); + +program.parse(); +``` + +- [ ] **Step 4: Install dependencies and verify** + +```bash +cd packages/cli && pnpm install +bun run src/index.ts --help +``` + +Expected: commander help output with program name and version. + +- [ ] **Step 5: Verify build** + +```bash +cd packages/cli && bun build ./src/index.ts --outdir ./dist --target=node +node dist/index.js --help +``` + +Expected: same help output. + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): scaffold CLI package with commander" +``` + +--- + +### Task 2: Config Module + +**Files:** +- Create: `packages/cli/src/config.ts` +- Create: `packages/cli/src/commands/config.ts` + +- [ ] **Step 1: Implement config read/write module** + +`packages/cli/src/config.ts`: +```ts +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; +import { homedir } from "os"; +import { join } from "path"; + +export interface CliConfig { + apiUrl: string; + apiKey: string; +} + +const CONFIG_DIR = join(homedir(), ".moemail"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +export function loadConfig(): CliConfig { + const config: CliConfig = { apiUrl: "", apiKey: "" }; + + // File config + if (existsSync(CONFIG_FILE)) { + try { + const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + if (raw.apiUrl) config.apiUrl = raw.apiUrl; + if (raw.apiKey) config.apiKey = raw.apiKey; + } catch {} + } + + // Env overrides (higher priority) + if (process.env.MOEMAIL_API_URL) config.apiUrl = process.env.MOEMAIL_API_URL; + if (process.env.MOEMAIL_API_KEY) config.apiKey = process.env.MOEMAIL_API_KEY; + + return config; +} + +export function saveConfig(key: string, value: string): void { + if (!existsSync(CONFIG_DIR)) { + mkdirSync(CONFIG_DIR, { recursive: true }); + } + + let config: Record = {}; + if (existsSync(CONFIG_FILE)) { + try { + config = JSON.parse(readFileSync(CONFIG_FILE, "utf-8")); + } catch {} + } + + // Map CLI key names to config keys + const keyMap: Record = { + "api-url": "apiUrl", + "api-key": "apiKey", + }; + + const configKey = keyMap[key]; + if (!configKey) { + throw new Error(`Unknown config key: ${key}. Valid keys: api-url, api-key`); + } + + config[configKey] = value; + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} +``` + +- [ ] **Step 2: Implement config command** + +`packages/cli/src/commands/config.ts`: +```ts +import { Command } from "commander"; +import { loadConfig, saveConfig } from "../config.js"; + +export function registerConfigCommand(program: Command) { + const cmd = program.command("config").description("Configure API endpoint and API Key"); + + cmd + .command("set ") + .description("Set a config value (api-url or api-key)") + .action((key: string, value: string) => { + try { + saveConfig(key, value); + console.error(`Set ${key} successfully.`); + } catch (e: any) { + console.error(e.message); + process.exit(1); + } + }); + + cmd + .command("list") + .description("Show current configuration") + .action(() => { + const config = loadConfig(); + console.log(`api-url: ${config.apiUrl || "(not set)"}`); + console.log(`api-key: ${config.apiKey ? config.apiKey.slice(0, 6) + "..." : "(not set)"}`); + }); +} +``` + +- [ ] **Step 3: Register in index.ts** + +Update `packages/cli/src/index.ts` to import and register: +```ts +#!/usr/bin/env node +import { Command } from "commander"; +import { registerConfigCommand } from "./commands/config.js"; + +const program = new Command(); + +program + .name("moemail") + .description("MoeMail CLI — Agent-friendly temporary email tool") + .version("0.1.0") + .option("--json", "output as JSON"); + +registerConfigCommand(program); + +program.parse(); +``` + +- [ ] **Step 4: Test manually** + +```bash +cd packages/cli +bun run src/index.ts config set api-url https://moemail.app +bun run src/index.ts config set api-key mk_test123 +bun run src/index.ts config list +cat ~/.moemail/config.json +``` + +Expected: config values saved and displayed correctly. + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add config module and config command" +``` + +--- + +### Task 3: Output Module + +**Files:** +- Create: `packages/cli/src/output.ts` + +- [ ] **Step 1: Implement output helpers** + +`packages/cli/src/output.ts`: +```ts +/** + * Print JSON to stdout (for --json mode). + */ +export function printJson(data: unknown): void { + console.log(JSON.stringify(data)); +} + +/** + * Print human-readable text to stdout. + */ +export function printText(text: string): void { + console.log(text); +} + +/** + * Log to stderr (progress, errors — never pollutes stdout). + */ +export function log(message: string): void { + console.error(message); +} + +/** + * Convert epoch ms timestamp to ISO 8601 string. + */ +export function msToIso(ms: number): string { + return new Date(ms).toISOString(); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/cli/src/output.ts +git commit -m "feat(cli): add output formatting helpers" +``` + +--- + +### Task 4: API Client + +**Files:** +- Create: `packages/cli/src/api.ts` + +- [ ] **Step 1: Implement API client** + +`packages/cli/src/api.ts`: +```ts +import { loadConfig } from "./config.js"; +import { log } from "./output.js"; + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + } +} + +async function request( + method: string, + path: string, + body?: Record, +): Promise { + const config = loadConfig(); + + if (!config.apiUrl) { + log("Error: API URL not configured. Run: moemail config set api-url "); + process.exit(2); + } + if (!config.apiKey) { + log("Error: API Key not configured. Run: moemail config set api-key "); + process.exit(2); + } + + const url = `${config.apiUrl.replace(/\/$/, "")}${path}`; + const headers: Record = { + "X-API-Key": config.apiKey, + }; + if (body) { + headers["Content-Type"] = "application/json"; + } + + const res = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + if (res.status === 401 || res.status === 403) { + log("Error: Authentication failed. Check your API Key."); + process.exit(2); + } + + if (res.status === 204) { + return null; + } + + const data = await res.json(); + + if (!res.ok) { + throw new ApiError(res.status, (data as any).error || `HTTP ${res.status}`); + } + + return data; +} + +export const api = { + getConfig: () => request("GET", "/api/config"), + + createEmail: (body: { name?: string; expiryTime: number; domain: string }) => + request("POST", "/api/emails/generate", body as any), + + listEmails: (cursor?: string) => + request("GET", `/api/emails${cursor ? `?cursor=${cursor}` : ""}`), + + listMessages: (emailId: string, cursor?: string) => + request("GET", `/api/emails/${emailId}${cursor ? `?cursor=${cursor}` : ""}`), + + getMessage: (emailId: string, messageId: string) => + request("GET", `/api/emails/${emailId}/${messageId}`), + + deleteEmail: (emailId: string) => + request("DELETE", `/api/emails/${emailId}`), + + deleteMessage: (emailId: string, messageId: string) => + request("DELETE", `/api/emails/${emailId}/${messageId}`), + + sendEmail: (emailId: string, body: { to: string; subject: string; content: string }) => + request("POST", `/api/emails/${emailId}/send`, body), +}; +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/cli/src/api.ts +git commit -m "feat(cli): add API client module" +``` + +--- + +### Task 5: Create Command + +**Files:** +- Create: `packages/cli/src/commands/create.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement create command** + +`packages/cli/src/commands/create.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +const EXPIRY_MAP: Record = { + "1h": 3600000, + "24h": 86400000, + "3d": 259200000, + permanent: 0, +}; + +export function registerCreateCommand(program: Command) { + program + .command("create") + .description("Create a temporary email address") + .option("--name ", "email prefix") + .option("--domain ", "email domain") + .option("--expiry ", "1h | 24h | 3d | permanent", "1h") + .action(async (opts) => { + const json = program.opts().json; + const expiryTime = EXPIRY_MAP[opts.expiry]; + if (expiryTime === undefined) { + log(`Error: Invalid expiry "${opts.expiry}". Valid: 1h, 24h, 3d, permanent`); + process.exit(1); + } + + try { + // If no domain specified, fetch from server config + let domain = opts.domain; + if (!domain) { + const config = (await api.getConfig()) as any; + const domains = config.emailDomains?.split(",").map((d: string) => d.trim()); + if (!domains?.length) { + log("Error: No email domains configured on server."); + process.exit(1); + } + domain = domains[0]; + } + + const result = (await api.createEmail({ + name: opts.name, + expiryTime, + domain, + })) as any; + + const expiresAt = + expiryTime === 0 + ? null + : msToIso(Date.now() + expiryTime); + + if (json) { + printJson({ + id: result.id, + address: result.email, + expiresAt, + }); + } else { + const expiryLabel = opts.expiry === "permanent" ? "permanent" : `expires in ${opts.expiry}`; + printText(`Created: ${result.email} (${expiryLabel})`); + printText(`ID: ${result.id}`); + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts** + +Add import and call `registerCreateCommand(program)` before `program.parse()`. + +- [ ] **Step 3: Test manually** + +```bash +cd packages/cli +bun run src/index.ts create --help +bun run src/index.ts create --domain moemail.app --expiry 1h --json +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add create command" +``` + +--- + +### Task 6: List Command + +**Files:** +- Create: `packages/cli/src/commands/list.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement list command** + +`packages/cli/src/commands/list.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +export function registerListCommand(program: Command) { + program + .command("list") + .description("List mailboxes or messages") + .option("--email-id ", "list messages in this mailbox") + .option("--cursor ", "pagination cursor") + .action(async (opts) => { + const json = program.opts().json; + try { + if (opts.emailId) { + // List messages + const data = (await api.listMessages(opts.emailId, opts.cursor)) as any; + if (json) { + printJson({ + messages: data.messages.map((m: any) => ({ + id: m.id, + from: m.from_address, + subject: m.subject, + receivedAt: m.received_at ? msToIso(m.received_at) : null, + })), + nextCursor: data.nextCursor, + total: data.total, + }); + } else { + if (!data.messages.length) { + printText("No messages."); + } else { + for (const m of data.messages) { + printText(`[${m.id}] From: ${m.from_address} — ${m.subject}`); + } + printText(`Total: ${data.total}`); + } + } + } else { + // List mailboxes + // Note: GET /api/emails returns raw Drizzle ORM objects. + // expiresAt is a Date serialized to ISO string (not epoch ms). + const data = (await api.listEmails(opts.cursor)) as any; + if (json) { + printJson({ + emails: data.emails.map((e: any) => ({ + id: e.id, + address: e.address, + expiresAt: e.expiresAt || null, // Already ISO string from server + })), + nextCursor: data.nextCursor, + total: data.total, + }); + } else { + if (!data.emails.length) { + printText("No mailboxes."); + } else { + for (const e of data.emails) { + printText(`[${e.id}] ${e.address}`); + } + printText(`Total: ${data.total}`); + } + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts and test** + +```bash +bun run src/index.ts list --json +bun run src/index.ts list --email-id --json +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add list command for mailboxes and messages" +``` + +--- + +### Task 7: Wait Command + +**Files:** +- Create: `packages/cli/src/commands/wait.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement wait command** + +`packages/cli/src/commands/wait.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export function registerWaitCommand(program: Command) { + program + .command("wait") + .description("Wait for a new email to arrive") + .requiredOption("--email-id ", "email ID to watch") + .option("--timeout ", "max wait time in seconds", "120") + .option("--interval ", "poll interval in seconds", "5") + .action(async (opts) => { + const json = program.opts().json; + const timeout = parseInt(opts.timeout, 10); + const interval = parseInt(opts.interval, 10); + const emailId = opts.emailId; + + try { + // Record initial message IDs + const initial = (await api.listMessages(emailId)) as any; + const knownIds = new Set(initial.messages.map((m: any) => m.id)); + + const startTime = Date.now(); + + while (true) { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + if (elapsed >= timeout) { + log(`Timeout: no new messages received within ${timeout}s`); + process.exit(1); + } + + log(`Polling... (${elapsed}/${timeout}s)`); + await sleep(interval * 1000); + + const current = (await api.listMessages(emailId)) as any; + const newMessages = current.messages.filter((m: any) => !knownIds.has(m.id)); + + if (newMessages.length > 0) { + const msg = newMessages[0]; + if (json) { + printJson({ + messageId: msg.id, + from: msg.from_address, + subject: msg.subject, + receivedAt: msg.received_at ? msToIso(msg.received_at) : null, + }); + } else { + printText(`New message from ${msg.from_address}: "${msg.subject}"`); + printText(`Message ID: ${msg.id}`); + } + return; + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts** + +- [ ] **Step 3: Test manually** + +```bash +# In one terminal, create an email and wait +bun run src/index.ts wait --email-id --timeout 30 --interval 3 --json +# Send a test email to it from another source to verify detection +``` + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add wait command with client-side polling" +``` + +--- + +### Task 8: Read Command + +**Files:** +- Create: `packages/cli/src/commands/read.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement read command** + +`packages/cli/src/commands/read.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText, msToIso } from "../output.js"; + +export function registerReadCommand(program: Command) { + program + .command("read") + .description("Read an email message") + .requiredOption("--email-id ", "email ID") + .requiredOption("--message-id ", "message ID") + .option("--format ", "text | html", "text") + .action(async (opts) => { + const json = program.opts().json; + try { + const data = (await api.getMessage(opts.emailId, opts.messageId)) as any; + const msg = data.message; + + if (json) { + printJson({ + id: msg.id, + from: msg.from_address, + to: msg.to_address, + subject: msg.subject, + content: msg.content, + html: msg.html, + receivedAt: msg.received_at ? msToIso(msg.received_at) : null, + type: msg.type, + }); + } else { + printText(`From: ${msg.from_address}`); + printText(`To: ${msg.to_address}`); + printText(`Subject: ${msg.subject}`); + printText(`---`); + if (opts.format === "html") { + printText(msg.html || "(no HTML content)"); + } else { + printText(msg.content || "(no text content)"); + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts and test** + +```bash +bun run src/index.ts read --email-id --message-id --json +bun run src/index.ts read --email-id --message-id --format html +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add read command" +``` + +--- + +### Task 9: Delete Command + +**Files:** +- Create: `packages/cli/src/commands/delete.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement delete command** + +`packages/cli/src/commands/delete.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText } from "../output.js"; + +export function registerDeleteCommand(program: Command) { + program + .command("delete") + .description("Delete a mailbox or message") + .requiredOption("--email-id ", "email ID") + .option("--message-id ", "message ID (omit to delete entire mailbox)") + .action(async (opts) => { + const json = program.opts().json; + try { + if (opts.messageId) { + await api.deleteMessage(opts.emailId, opts.messageId); + if (json) { + printJson({ success: true }); + } else { + printText(`Deleted message ${opts.messageId}`); + } + } else { + await api.deleteEmail(opts.emailId); + if (json) { + printJson({ success: true }); + } else { + printText(`Deleted mailbox ${opts.emailId}`); + } + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts and test** + +```bash +bun run src/index.ts delete --email-id --json +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add delete command" +``` + +--- + +### Task 10: Server-Side Fix — Send Endpoint Auth + +**Files:** +- Modify: `app/api/emails/[id]/send/route.ts` + +This is required before the CLI `send` command can work with API keys. + +- [ ] **Step 1: Read current send route** + +Read `app/api/emails/[id]/send/route.ts` to locate the `auth()` call. + +- [ ] **Step 2: Replace session auth with dual auth** + +Three changes needed in the file: + +**a) Import `getUserId` and replace auth call (line 2, 52-58):** +```ts +// Add import: +import { getUserId } from "@/lib/apiKey" + +// Replace lines 52-58: +// Before: +const session = await auth() +if (!session?.user?.id) { + return NextResponse.json({ error: "未授权" }, { status: 401 }) +} + +// After: +const userId = await getUserId() +if (!userId) { + return NextResponse.json({ error: "未授权" }, { status: 401 }) +} +``` + +**b) Update checkSendPermission call (line 63):** +```ts +// Before: +const permissionResult = await checkSendPermission(session.user.id) + +// After: +const permissionResult = await checkSendPermission(userId) +``` + +**c) Update ownership check (line 93):** +```ts +// Before: +if (email.userId !== session.user.id) { + +// After: +if (email.userId !== userId) { +``` + +Remove the now-unused `import { auth } from "@/lib/auth"` if no other usage remains. + +- [ ] **Step 3: Verify the existing web UI still works** + +Start the dev server and test sending an email through the web interface to confirm the change doesn't break session-based auth. + +- [ ] **Step 4: Commit** + +```bash +git add app/api/emails/[id]/send/route.ts +git commit -m "fix(api): support API Key auth for send endpoint" +``` + +--- + +### Task 11: Send Command + +**Files:** +- Create: `packages/cli/src/commands/send.ts` +- Modify: `packages/cli/src/index.ts` + +- [ ] **Step 1: Implement send command** + +`packages/cli/src/commands/send.ts`: +```ts +import { Command } from "commander"; +import { api } from "../api.js"; +import { log, printJson, printText } from "../output.js"; + +export function registerSendCommand(program: Command) { + program + .command("send") + .description("Send an email from a temporary address") + .requiredOption("--email-id ", "email ID to send from") + .requiredOption("--to
", "recipient email address") + .requiredOption("--subject ", "email subject") + .requiredOption("--content ", "email body text") + .action(async (opts) => { + const json = program.opts().json; + try { + const result = (await api.sendEmail(opts.emailId, { + to: opts.to, + subject: opts.subject, + content: opts.content, + })) as any; + + if (json) { + printJson({ + success: true, + remainingEmails: result.remainingEmails, + }); + } else { + printText(`Email sent successfully. Remaining today: ${result.remainingEmails}`); + } + } catch (e: any) { + log(`Error: ${e.message}`); + process.exit(1); + } + }); +} +``` + +- [ ] **Step 2: Register in index.ts and test** + +```bash +bun run src/index.ts send --email-id --to test@example.com --subject "Test" --content "Hello" --json +``` + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/ +git commit -m "feat(cli): add send command" +``` + +--- + +### Task 12: README + +**Files:** +- Create: `packages/cli/README.md` + +- [ ] **Step 1: Write README** + +Include: +- One-line description: "Agent-first CLI for MoeMail temporary email service" +- Install: `npm i -g moemail-cli` +- Quick start (3 steps: config → create → wait) +- Command reference table (all 7 commands with key flags) +- Agent workflow example (the bash script from the spec) +- JSON output format note +- Exit codes table +- Link to MoeMail main project + +- [ ] **Step 2: Commit** + +```bash +git add packages/cli/README.md +git commit -m "docs(cli): add README with usage guide and agent workflow" +``` + +--- + +### Task 13: llms.txt + +**Files:** +- Create: `public/llms.txt` + +- [ ] **Step 1: Create llms.txt** + +Copy the content from the spec's "llms.txt" section into `public/llms.txt`. + +- [ ] **Step 2: Verify it's accessible** + +With the dev server running, visit `http://localhost:3000/llms.txt` and confirm it serves the text file. + +- [ ] **Step 3: Commit** + +```bash +git add public/llms.txt +git commit -m "feat: add llms.txt for AI agent discoverability" +``` + +--- + +### Task 14: CI/CD — Publish Workflow + +**Files:** +- Create: `.github/workflows/publish-cli.yml` + +- [ ] **Step 1: Create workflow file** + +`.github/workflows/publish-cli.yml`: +```yaml +name: Publish CLI + +on: + push: + tags: + - 'cli-v*' + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v2 + with: + version: 9 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + cache: 'pnpm' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: cd packages/cli && pnpm install --frozen-lockfile + + - name: Build + run: cd packages/cli && bun build ./src/index.ts --outdir ./dist --target=node + + - name: Publish to npm + run: cd packages/cli && npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} +``` + +- [ ] **Step 2: Commit** + +```bash +git add .github/workflows/publish-cli.yml +git commit -m "ci: add CLI publish workflow triggered by cli-v* tags" +``` + +--- + +### Task 15: Final Integration Test + +- [ ] **Step 1: Full workflow test** + +Run the complete agent workflow from the spec against the running dev server: + +```bash +cd packages/cli + +# Configure +bun run src/index.ts config set api-url http://localhost:3000 +bun run src/index.ts config set api-key + +# Create +EMAIL=$(bun run src/index.ts create --expiry 1h --json) +echo $EMAIL + +# List +bun run src/index.ts list --json + +# Wait (in background, then send test email) +# This requires a real email to be sent to the temp address + +# Read (use a message ID from list) +bun run src/index.ts list --email-id --json + +# Delete +bun run src/index.ts delete --email-id --json +``` + +- [ ] **Step 2: Build and test as npm package** + +```bash +cd packages/cli +bun build ./src/index.ts --outdir ./dist --target=node +node dist/index.js --version +node dist/index.js --help +node dist/index.js config list +``` + +- [ ] **Step 3: Final commit** + +```bash +git add -A +git commit -m "feat(cli): complete CLI implementation with all commands" +```