@atomicmail/agent-skill 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.
package/SKILL.md ADDED
@@ -0,0 +1,242 @@
1
+ ---
2
+ name: atomic-mail
3
+ description: Read and write email through the Atomic Mail ESP from an AI agent. Handles the proof-of-work authentication and JMAP protocol so the agent only needs to think in JMAP method calls. Use when the user asks to register an Atomic Mail inbox, list mailboxes, fetch emails, send email, or otherwise programmatically interact with their Atomic Mail account.
4
+ license: MIT
5
+ compatibility: Requires Deno 2.0+ to run scripts directly, or Node 20+ / Bun 1.1+ via `npx @atomic-mail/agent-skill` after publishing. Needs network access to the configured Atomic Mail auth-service and api-service.
6
+ metadata:
7
+ author: atomic-mail
8
+ version: "0.1.0"
9
+ ---
10
+
11
+ # Atomic Mail
12
+
13
+ Atomic Mail is an AI-agent-first email service provider (ESP) that exposes a
14
+ programmable inbox over JMAP. It uses a custom proof-of-work (PoW) signup flow
15
+ plus three short-lived JWTs (challenge -> session -> capability). This skill
16
+ hides all of that behind two CLI scripts so the agent can focus on JMAP.
17
+
18
+ ## When to use this skill
19
+
20
+ Use this skill when the user wants to:
21
+
22
+ - Register a new Atomic Mail inbox (signup with a username).
23
+ - Re-authenticate an existing Atomic Mail account using its API key.
24
+ - Read, search, or modify email via JMAP (`Mailbox/get`, `Email/query`,
25
+ `Email/get`, `Email/set`, etc.).
26
+ - Send email via JMAP (`EmailSubmission/set` with the
27
+ `urn:ietf:params:jmap:submission` capability).
28
+ - Discover the JMAP session object (`/.well-known/jmap`) to find the `accountId`
29
+ before issuing other JMAP method calls.
30
+
31
+ ## Available scripts
32
+
33
+ - **`scripts/signup.ts`** — One-time setup: performs PoW signup or login and
34
+ writes credentials to disk. Run once per agent session/inbox.
35
+ - **`scripts/jmap_request.ts`** — Sends JMAP requests using the saved
36
+ credentials. Auto-rotates the session and capability JWTs as they expire.
37
+
38
+ Both scripts are invokable three ways. Pick the one that matches the runtime the
39
+ user has installed:
40
+
41
+ ```bash
42
+ # Deno (preferred — runs source directly)
43
+ deno run -A scripts/signup.ts ...
44
+ deno run -A scripts/jmap_request.ts ...
45
+
46
+ # Node (after the npm package is installed/published)
47
+ npx -y @atomic-mail/agent-skill atomic-mail-signup ...
48
+ npx -y @atomic-mail/agent-skill atomic-mail-jmap ...
49
+
50
+ # Bun
51
+ bunx -y @atomic-mail/agent-skill atomic-mail-signup ...
52
+ bunx -y @atomic-mail/agent-skill atomic-mail-jmap ...
53
+ ```
54
+
55
+ > The agent should always pass `--help` first if it is unsure of the exact flag
56
+ > spelling. Both scripts print full usage to stdout and exit `0`.
57
+
58
+ ## Required configuration
59
+
60
+ The auth and API base URLs come from the Atomic Mail deployment. Pass them as
61
+ flags or set them in the environment:
62
+
63
+ | Flag | Env var | Description |
64
+ | --------------- | ------------------------- | ------------------------------------------------------- |
65
+ | `--auth-url` | `ATOMIC_MAIL_AUTH_URL` | Base URL of `auth-service` (PoW + JWT minting). |
66
+ | `--api-url` | `ATOMIC_MAIL_API_URL` | Base URL of `api-service` (JMAP). |
67
+ | `--scrypt-salt` | `ATOMIC_MAIL_SCRYPT_SALT` | Optional PoW salt override (defaults match `auth-service`). |
68
+
69
+ If the user does not know the URLs, ask them — they are deployment-specific.
70
+
71
+ ## Workflow
72
+
73
+ ### 1. First-time signup (new account)
74
+
75
+ ```bash
76
+ deno run -A scripts/signup.ts \
77
+ --auth-url "$ATOMIC_MAIL_AUTH_URL" \
78
+ --api-url "$ATOMIC_MAIL_API_URL" \
79
+ --username "alice" \
80
+ --out-dir "./.atomic-mail"
81
+ ```
82
+
83
+ This writes three files into `--out-dir`:
84
+
85
+ - `credentials.json` — `{ apiKey, inboxId, authUrl, apiUrl, scryptSalt }`. The
86
+ agent should store the `apiKey` securely; it is the long-lived secret.
87
+ - `session.jwt` — 4-hour session token.
88
+ - `capability.jwt` — 2-minute capability token used as the JMAP bearer.
89
+
90
+ The script prints a JSON summary to stdout that includes `inboxId` and `apiKey`.
91
+ Save these in the agent's persistent memory (or echo them back to the user) —
92
+ they are the only durable identifiers.
93
+
94
+ ### 2. Re-authenticate (existing API key)
95
+
96
+ If `credentials.json` already exists, this is normally not needed —
97
+ `jmap_request.ts` will auto-renew session/capability tokens via the stored API
98
+ key. Use `signup.ts --api-key` only if the user wants to start a fresh
99
+ credentials directory from a known API key.
100
+
101
+ ```bash
102
+ deno run -A scripts/signup.ts \
103
+ --auth-url "$ATOMIC_MAIL_AUTH_URL" \
104
+ --api-url "$ATOMIC_MAIL_API_URL" \
105
+ --api-key "11111111-2222-3333-4444-555555555555" \
106
+ --out-dir "./.atomic-mail"
107
+ ```
108
+
109
+ ### 3. Discover the JMAP session
110
+
111
+ Run this **once** before any other JMAP call to learn the `accountId` and
112
+ mailbox structure.
113
+
114
+ ```bash
115
+ deno run -A scripts/jmap_request.ts \
116
+ --credentials-dir "./.atomic-mail" \
117
+ --session
118
+ ```
119
+
120
+ The response JSON contains `primaryAccounts`, `accounts`, `capabilities`, etc.
121
+ Extract the `accountId` for the user's primary mail account from
122
+ `primaryAccounts["urn:ietf:params:jmap:mail"]`.
123
+
124
+ ### 4. Send a JMAP request (inline ops)
125
+
126
+ ```bash
127
+ deno run -A scripts/jmap_request.ts \
128
+ --credentials-dir "./.atomic-mail" \
129
+ --ops '[["Mailbox/get", {"accountId": "ACCOUNT_ID"}, "m0"]]'
130
+ ```
131
+
132
+ For multiple method calls or capabilities, pass a full envelope:
133
+
134
+ ```bash
135
+ deno run -A scripts/jmap_request.ts \
136
+ --credentials-dir "./.atomic-mail" \
137
+ --ops '{
138
+ "using": ["urn:ietf:params:jmap:core","urn:ietf:params:jmap:mail"],
139
+ "methodCalls": [
140
+ ["Mailbox/get", {"accountId": "ACCOUNT_ID"}, "m0"],
141
+ ["Email/query", {"accountId": "ACCOUNT_ID", "limit": 10}, "q0"]
142
+ ]
143
+ }'
144
+ ```
145
+
146
+ ### 5. Send a JMAP request (preset file)
147
+
148
+ For repeated tasks, save the JMAP body to a file and reuse it. The file may
149
+ contain either a `methodCalls` array or a full `{ using, methodCalls }`
150
+ envelope.
151
+
152
+ ```bash
153
+ cat > fetch_last_100.json <<'EOF'
154
+ [
155
+ ["Email/query", {
156
+ "accountId": "ACCOUNT_ID",
157
+ "limit": 100,
158
+ "sort": [{ "property": "receivedAt", "isAscending": false }]
159
+ }, "q0"],
160
+ ["Email/get", {
161
+ "accountId": "ACCOUNT_ID",
162
+ "#ids": { "resultOf": "q0", "name": "Email/query", "path": "/ids" },
163
+ "properties": ["id","threadId","subject","from","receivedAt","preview"]
164
+ }, "g0"]
165
+ ]
166
+ EOF
167
+
168
+ deno run -A scripts/jmap_request.ts \
169
+ --credentials-dir "./.atomic-mail" \
170
+ --ops-file fetch_last_100.json
171
+ ```
172
+
173
+ The agent can build a small library of these preset files (`send_email.json`,
174
+ `mark_read.json`, etc.) and reuse them across runs.
175
+
176
+ ### 6. Send email
177
+
178
+ Send is a JMAP `EmailSubmission/set` plus an optional `Email/set` to upload the
179
+ draft. Remember to add the submission capability to `using`:
180
+
181
+ ```bash
182
+ deno run -A scripts/jmap_request.ts \
183
+ --credentials-dir "./.atomic-mail" \
184
+ --using "urn:ietf:params:jmap:core,urn:ietf:params:jmap:mail,urn:ietf:params:jmap:submission" \
185
+ --ops-file send_email.json
186
+ ```
187
+
188
+ ## Token rotation (handled automatically)
189
+
190
+ `jmap_request.ts` checks both JWTs before every request:
191
+
192
+ - If `capability.jwt` is within 20 s of expiry, it calls
193
+ `POST /api/v1/capability` with the existing session JWT and rewrites
194
+ `capability.jwt`.
195
+ - If `session.jwt` is within 60 s of expiry (or missing), it re-runs the full
196
+ PoW handshake using the API key from `credentials.json`, then rewrites both
197
+ `session.jwt` and `capability.jwt`.
198
+
199
+ The agent does not need to call `signup.ts` again to refresh tokens — it only
200
+ needs to call `signup.ts` for the very first registration of an account.
201
+
202
+ ## Troubleshooting
203
+
204
+ - **`Could not read credentials file ... Did you run signup first?`** — Run
205
+ `signup.ts` once with `--username` (new account) or `--api-key` (existing
206
+ account) to create the file set.
207
+ - **`auth-service /api/v1/session returned 409`** — The challenge was consumed
208
+ (likely a duplicate request or a clock skew). Just rerun `signup.ts` or the
209
+ failing `jmap_request.ts` once; a fresh challenge will be issued.
210
+ - **`auth-service /api/v1/session returned 401`** — The `apiKey` in
211
+ `credentials.json` is unknown or suspended. Re-register with `--username` or
212
+ get a new API key from the operator.
213
+ - **PoW takes a long time on first run** — Difficulty is fixed at 6 leading zero
214
+ bits, which averages ~64 scrypt iterations. Each scrypt is ~16 MB and ~200-500
215
+ ms, so the whole solve typically completes in under 30 seconds on a modern
216
+ laptop.
217
+ - **`Capability JWT did not contain an inboxId claim`** — Almost certainly a
218
+ server/version mismatch. Verify `--auth-url` points to a current
219
+ `auth-service` deployment.
220
+
221
+ ## Security notes
222
+
223
+ - `credentials.json` contains the long-lived API key. The script writes it with
224
+ mode `0600`, but the agent must not echo the file's contents into shared logs
225
+ or commit it to source control.
226
+ - Pick a credentials directory that is private to the agent's runtime user (e.g.
227
+ `~/.config/atomic-mail/` or a per-task working dir).
228
+ - `session.jwt` and `capability.jwt` are short-lived but should be treated as
229
+ bearer credentials too — never log them.
230
+
231
+ ## Building an npm package
232
+
233
+ Scripts can be published as an npm package so Node and Bun environments can use
234
+ them through `npx` / `bunx`. From the skill directory:
235
+
236
+ ```bash
237
+ deno task build:npm # writes ./npm
238
+ cd npm && npm publish --access public
239
+ ```
240
+
241
+ After publishing, `npx @atomic-mail/agent-skill atomic-mail-signup` and
242
+ `npx @atomic-mail/agent-skill atomic-mail-jmap` work without Deno installed.
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Fixed proof-of-work scrypt salt. The auth-service passes this string (UTF-8
3
+ * bytes of the hex text, not decoded binary) to `scrypt` as the `salt`
4
+ * argument; all PoW clients must use the same value.
5
+ */
6
+ export declare const DEFAULT_POW_SCRYPT_SALT_HEX = "0b980734412c292d6549110276b604ab1dea4883bd460d77d1b984adf8bca083";
7
+ export declare const ONE_SEC_MS = 1000;
8
+ export declare const ONE_MIN_MS: number;
9
+ export declare const ONE_HOUR_MS: number;
10
+ export declare const ONE_DAY_MS: number;
11
+ export declare const ONE_MONTH_MS: number;
12
+ export declare const ONE_YEAR_MS: number;
13
+ //# sourceMappingURL=consts.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../../../src/lib/src/consts.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,eAAO,MAAM,2BAA2B,qEAC4B,CAAC;AAErE,eAAO,MAAM,UAAU,OAAO,CAAC;AAC/B,eAAO,MAAM,UAAU,QAAkB,CAAC;AAC1C,eAAO,MAAM,WAAW,QAAkB,CAAC;AAC3C,eAAO,MAAM,UAAU,QAAmB,CAAC;AAC3C,eAAO,MAAM,YAAY,QAAkB,CAAC;AAC5C,eAAO,MAAM,WAAW,QAAmB,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Fixed proof-of-work scrypt salt. The auth-service passes this string (UTF-8
3
+ * bytes of the hex text, not decoded binary) to `scrypt` as the `salt`
4
+ * argument; all PoW clients must use the same value.
5
+ */
6
+ export const DEFAULT_POW_SCRYPT_SALT_HEX = "0b980734412c292d6549110276b604ab1dea4883bd460d77d1b984adf8bca083";
7
+ export const ONE_SEC_MS = 1000;
8
+ export const ONE_MIN_MS = ONE_SEC_MS * 60;
9
+ export const ONE_HOUR_MS = ONE_MIN_MS * 60;
10
+ export const ONE_DAY_MS = ONE_HOUR_MS * 24;
11
+ export const ONE_MONTH_MS = ONE_DAY_MS * 30;
12
+ export const ONE_YEAR_MS = ONE_DAY_MS * 365;
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=jmap_request.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jmap_request.d.ts","sourceRoot":"","sources":["../../../src/skill/scripts/jmap_request.ts"],"names":[],"mappings":""}
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env node
2
+ // Atomic Mail skill: JMAP request.
3
+ //
4
+ // Sends a JMAP request to api-service using credentials previously written
5
+ // by signup. Auto-rotates session JWT (re-running PoW) and capability JWT
6
+ // when they are within their safety margin of expiry, and writes the rotated
7
+ // tokens back to disk so subsequent invocations stay fresh.
8
+ //
9
+ // JMAP method calls can be supplied two ways:
10
+ // --ops '<json>' inline JSON body
11
+ // --ops-file <path> read JSON body from a file (e.g. saved
12
+ // "fetch_last_100.json" preset)
13
+ //
14
+ // In both cases, the JSON may be either:
15
+ // • an array of methodCalls, e.g. [["Mailbox/get", {...}, "m0"]]
16
+ // • a full envelope { using: [...], methodCalls: [...] }
17
+ import process from "node:process";
18
+ import { parseArgs } from "node:util";
19
+ import { readFile } from "node:fs/promises";
20
+ import { CAPABILITY_SAFETY_MARGIN_MS, fetchCapability, isJwtExpired, performPoWAndSession, SESSION_SAFETY_MARGIN_MS, } from "./lib/auth.js";
21
+ import { defaultFilesFromOutDir, readCredentials, tryReadJwtFile, writeJwtFile, } from "./lib/credentials.js";
22
+ const DEFAULT_USING = [
23
+ "urn:ietf:params:jmap:core",
24
+ "urn:ietf:params:jmap:mail",
25
+ ];
26
+ const HELP = `Usage: atomic-mail-jmap [OPTIONS]
27
+
28
+ Send a JMAP request to your Atomic Mail inbox using credentials previously
29
+ written by atomic-mail-signup. Capability and session JWTs are renewed
30
+ automatically when expired.
31
+
32
+ Token sources (defaults are based on --credentials-dir):
33
+ --credentials-dir DIR Directory containing credentials.json,
34
+ session.jwt, capability.jwt. Default: cwd.
35
+ --credentials-file PATH Override path to credentials.json.
36
+ --session-file PATH Override path to session.jwt.
37
+ --capability-file PATH Override path to capability.jwt.
38
+
39
+ Request body (one of these is required):
40
+ --ops JSON Inline JSON: either an array of methodCalls or
41
+ a full { using, methodCalls } object.
42
+ --ops-file PATH Read --ops content from a file. Useful for
43
+ saving reusable presets like 'fetch_last_100.json'.
44
+ --session Fetch JMAP session metadata via
45
+ GET /.well-known/jmap (no body).
46
+
47
+ Other:
48
+ --using LIST Comma-separated capability URNs. Overrides
49
+ defaults: ${DEFAULT_USING.join(",")}.
50
+ Ignored if the JSON envelope already sets 'using'.
51
+ --dry-run Print the resolved request to stdout without
52
+ sending it. Tokens are NOT rotated.
53
+ --help, -h Show this help.
54
+
55
+ Exit codes:
56
+ 0 success — server response printed to stdout
57
+ 1 network error or HTTP non-2xx response from api-service / auth-service
58
+ 2 invalid CLI usage / malformed credentials or ops JSON
59
+
60
+ Examples:
61
+ # Inline ops
62
+ atomic-mail-jmap --ops '[["Mailbox/get", {"accountId":"abc"}, "m0"]]'
63
+
64
+ # Reusable preset file
65
+ echo '[["Email/query",{"accountId":"abc","limit":100,"sort":[{"property":"receivedAt","isAscending":false}]},"q0"]]' \\
66
+ > fetch_last_100.json
67
+ atomic-mail-jmap --ops-file fetch_last_100.json
68
+
69
+ # Session discovery
70
+ atomic-mail-jmap --session
71
+ `;
72
+ function fail(message, code = 1) {
73
+ process.stderr.write(`Error: ${message}\n`);
74
+ if (code === 2)
75
+ process.stderr.write("\nRun with --help for usage.\n");
76
+ process.exit(code);
77
+ }
78
+ function readArgs() {
79
+ let parsed;
80
+ try {
81
+ parsed = parseArgs({
82
+ args: process.argv.slice(2),
83
+ options: {
84
+ "credentials-dir": { type: "string" },
85
+ "credentials-file": { type: "string" },
86
+ "session-file": { type: "string" },
87
+ "capability-file": { type: "string" },
88
+ ops: { type: "string" },
89
+ "ops-file": { type: "string" },
90
+ using: { type: "string" },
91
+ session: { type: "boolean" },
92
+ "dry-run": { type: "boolean" },
93
+ help: { type: "boolean", short: "h" },
94
+ },
95
+ strict: true,
96
+ allowPositionals: false,
97
+ });
98
+ }
99
+ catch (err) {
100
+ fail(err.message, 2);
101
+ }
102
+ if (parsed.values.help) {
103
+ process.stdout.write(HELP);
104
+ process.exit(0);
105
+ }
106
+ const dir = parsed.values["credentials-dir"] ?? ".";
107
+ const defaults = defaultFilesFromOutDir(dir);
108
+ const credentialsFile = parsed.values["credentials-file"] ??
109
+ defaults.credentialsFile;
110
+ const sessionFile = parsed.values["session-file"] ??
111
+ defaults.sessionFile;
112
+ const capabilityFile = parsed.values["capability-file"] ??
113
+ defaults.capabilityFile;
114
+ const ops = parsed.values.ops;
115
+ const opsFile = parsed.values["ops-file"];
116
+ const sessionMode = parsed.values.session === true;
117
+ if (sessionMode && (ops || opsFile)) {
118
+ fail("--session cannot be combined with --ops or --ops-file.", 2);
119
+ }
120
+ if (!sessionMode && !ops && !opsFile) {
121
+ fail("Provide one of --ops, --ops-file, or --session.", 2);
122
+ }
123
+ if (ops && opsFile) {
124
+ fail("--ops and --ops-file are mutually exclusive.", 2);
125
+ }
126
+ const usingFlag = parsed.values.using;
127
+ const using = usingFlag
128
+ ? usingFlag.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
129
+ : undefined;
130
+ return {
131
+ credentialsFile,
132
+ sessionFile,
133
+ capabilityFile,
134
+ ops,
135
+ opsFile,
136
+ using,
137
+ sessionMode,
138
+ dryRun: parsed.values["dry-run"] === true,
139
+ };
140
+ }
141
+ async function loadOps(args) {
142
+ if (args.sessionMode)
143
+ return undefined;
144
+ let raw;
145
+ if (args.ops !== undefined) {
146
+ raw = args.ops;
147
+ }
148
+ else if (args.opsFile) {
149
+ try {
150
+ raw = await readFile(args.opsFile, "utf-8");
151
+ }
152
+ catch (err) {
153
+ fail(`Could not read --ops-file '${args.opsFile}': ${err.message}`, 2);
154
+ }
155
+ }
156
+ else {
157
+ fail("Internal: ops not provided", 2);
158
+ }
159
+ let value;
160
+ try {
161
+ value = JSON.parse(raw);
162
+ }
163
+ catch (err) {
164
+ fail(`Invalid JSON in JMAP ops: ${err.message}`, 2);
165
+ }
166
+ let using = args.using ?? DEFAULT_USING;
167
+ let methodCalls;
168
+ if (Array.isArray(value)) {
169
+ methodCalls = value;
170
+ }
171
+ else if (value !== null &&
172
+ typeof value === "object" &&
173
+ Array.isArray(value.methodCalls)) {
174
+ const obj = value;
175
+ methodCalls = obj.methodCalls;
176
+ // Inline `using` only takes precedence if user did not pass --using.
177
+ if (Array.isArray(obj.using) && !args.using) {
178
+ using = obj.using.filter((u) => typeof u === "string");
179
+ }
180
+ }
181
+ else {
182
+ fail('JMAP ops must be a methodCalls array, e.g. [["Mailbox/get",{...},"m0"]], ' +
183
+ "or an object with a 'methodCalls' array.", 2);
184
+ }
185
+ return { using, methodCalls };
186
+ }
187
+ async function ensureFreshTokens(args) {
188
+ const credentials = await readCredentials(args.credentialsFile);
189
+ let sessionJWT = await tryReadJwtFile(args.sessionFile);
190
+ let capabilityJWT = await tryReadJwtFile(args.capabilityFile);
191
+ const sessionExpired = !sessionJWT ||
192
+ isJwtExpired(sessionJWT, SESSION_SAFETY_MARGIN_MS);
193
+ if (sessionExpired) {
194
+ process.stderr.write("Session JWT missing or near expiry — re-running PoW.\n");
195
+ const result = await performPoWAndSession({
196
+ authUrl: credentials.authUrl,
197
+ scryptSalt: credentials.scryptSalt,
198
+ apiKey: credentials.apiKey,
199
+ });
200
+ sessionJWT = result.sessionJWT;
201
+ // After session rotation, the previous capability JWT is also no longer
202
+ // recognized by auth-service (challenge-status cache reset), so force
203
+ // a refresh below.
204
+ capabilityJWT = undefined;
205
+ await writeJwtFile(args.sessionFile, sessionJWT);
206
+ }
207
+ const capabilityExpired = !capabilityJWT ||
208
+ isJwtExpired(capabilityJWT, CAPABILITY_SAFETY_MARGIN_MS);
209
+ if (capabilityExpired) {
210
+ process.stderr.write("Capability JWT missing or near expiry — refreshing.\n");
211
+ capabilityJWT = await fetchCapability(credentials.authUrl, sessionJWT);
212
+ await writeJwtFile(args.capabilityFile, capabilityJWT);
213
+ }
214
+ return {
215
+ capabilityJWT: capabilityJWT,
216
+ sessionJWT: sessionJWT,
217
+ credentials,
218
+ };
219
+ }
220
+ async function main() {
221
+ const args = readArgs();
222
+ const envelope = await loadOps(args);
223
+ if (args.dryRun) {
224
+ const credentials = await readCredentials(args.credentialsFile);
225
+ const url = args.sessionMode
226
+ ? `${credentials.apiUrl}/.well-known/jmap`
227
+ : `${credentials.apiUrl}/jmap/`;
228
+ const dryRun = {
229
+ method: args.sessionMode ? "GET" : "POST",
230
+ url,
231
+ headers: { Authorization: "Bearer <capability-jwt>" },
232
+ body: envelope ?? null,
233
+ };
234
+ process.stdout.write(JSON.stringify(dryRun, null, 2) + "\n");
235
+ return;
236
+ }
237
+ const tokens = await ensureFreshTokens(args);
238
+ if (args.sessionMode) {
239
+ const res = await fetch(`${tokens.credentials.apiUrl}/.well-known/jmap`, {
240
+ headers: { Authorization: `Bearer ${tokens.capabilityJWT}` },
241
+ });
242
+ const body = await res.text();
243
+ if (!res.ok) {
244
+ fail(`JMAP session fetch failed (HTTP ${res.status}): ${body}`);
245
+ }
246
+ process.stdout.write(body.endsWith("\n") ? body : body + "\n");
247
+ return;
248
+ }
249
+ const res = await fetch(`${tokens.credentials.apiUrl}/jmap/`, {
250
+ method: "POST",
251
+ headers: {
252
+ "Content-Type": "application/json",
253
+ Authorization: `Bearer ${tokens.capabilityJWT}`,
254
+ },
255
+ body: JSON.stringify(envelope),
256
+ });
257
+ const body = await res.text();
258
+ if (!res.ok) {
259
+ fail(`JMAP request failed (HTTP ${res.status}): ${body}`);
260
+ }
261
+ process.stdout.write(body.endsWith("\n") ? body : body + "\n");
262
+ }
263
+ main().catch((err) => {
264
+ fail(err instanceof Error ? err.message : String(err));
265
+ });
@@ -0,0 +1,42 @@
1
+ export declare const SESSION_TTL_MS: number;
2
+ export declare const CAPABILITY_TTL_MS: number;
3
+ export declare const CAPABILITY_SAFETY_MARGIN_MS = 20000;
4
+ export declare const SESSION_SAFETY_MARGIN_MS = 60000;
5
+ export interface JwtPayload {
6
+ exp?: number;
7
+ iat?: number;
8
+ jti?: string;
9
+ [key: string]: unknown;
10
+ }
11
+ export declare function decodeJwtPayload<T = JwtPayload>(jwt: string): T;
12
+ export declare function isJwtExpired(jwt: string, marginMs: number): boolean;
13
+ export declare function solvePow(challenge: string, difficulty: number, salt: string, onProgress?: (nonce: bigint) => void): Promise<{
14
+ powHex: string;
15
+ nonce: string;
16
+ }>;
17
+ export declare function fetchChallenge(authUrl: string): Promise<{
18
+ challengeJWT: string;
19
+ challenge: string;
20
+ difficulty: number;
21
+ }>;
22
+ export interface SessionResponse {
23
+ sessionJWT: string;
24
+ apiKey?: string;
25
+ }
26
+ export declare function exchangeSession(authUrl: string, body: {
27
+ challengeJWT: string;
28
+ powHex: string;
29
+ nonce: string;
30
+ apiKey?: string;
31
+ username?: string;
32
+ }): Promise<SessionResponse>;
33
+ export declare function fetchCapability(authUrl: string, sessionJWT: string): Promise<string>;
34
+ export interface PerformPoWInput {
35
+ authUrl: string;
36
+ scryptSalt: string;
37
+ apiKey?: string;
38
+ username?: string;
39
+ onPowProgress?: (nonce: bigint) => void;
40
+ }
41
+ export declare function performPoWAndSession(input: PerformPoWInput): Promise<SessionResponse>;
42
+ //# sourceMappingURL=auth.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.d.ts","sourceRoot":"","sources":["../../../../src/skill/scripts/lib/auth.ts"],"names":[],"mappings":"AAWA,eAAO,MAAM,cAAc,QAAqB,CAAC;AACjD,eAAO,MAAM,iBAAiB,QAAgB,CAAC;AAI/C,eAAO,MAAM,2BAA2B,QAAS,CAAC;AAClD,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAE/C,MAAM,WAAW,UAAU;IACzB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,GAAG,UAAU,EAAE,GAAG,EAAE,MAAM,GAAG,CAAC,CAc/D;AAED,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAQnE;AAsCD,wBAAsB,QAAQ,CAC5B,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,UAAU,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,GACnC,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,CAU5C;AAiCD,wBAAsB,cAAc,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC;IAC7D,YAAY,EAAE,MAAM,CAAC;IACrB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;CACpB,CAAC,CAqBD;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,IAAI,EAAE;IACJ,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GACA,OAAO,CAAC,eAAe,CAAC,CAS1B;AAED,wBAAsB,eAAe,CACnC,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,CAAC,CAUjB;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzC;AAKD,wBAAsB,oBAAoB,CACxC,KAAK,EAAE,eAAe,GACrB,OAAO,CAAC,eAAe,CAAC,CAgB1B"}
@@ -0,0 +1,163 @@
1
+ // PoW + auth-service client used by signup.ts and jmap_request.ts.
2
+ //
3
+ // Mirrors services/auth-service/src/crypto.ts and the protocol implemented
4
+ // in services/mcp-server-local/src/auth-session.ts. Uses only `node:` imports
5
+ // so the same source runs on Deno, Node, and Bun unchanged.
6
+ import { scrypt } from "node:crypto";
7
+ const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
8
+ const POW_HASH_BYTES = 64;
9
+ export const SESSION_TTL_MS = 4 * 60 * 60 * 1000;
10
+ export const CAPABILITY_TTL_MS = 2 * 60 * 1000;
11
+ // Refresh window: how close to expiry before we re-issue. The server enforces
12
+ // strict expiry, so the client must rotate before the wire-side TTL elapses.
13
+ export const CAPABILITY_SAFETY_MARGIN_MS = 20_000;
14
+ export const SESSION_SAFETY_MARGIN_MS = 60_000;
15
+ export function decodeJwtPayload(jwt) {
16
+ const parts = jwt.split(".");
17
+ if (parts.length < 2) {
18
+ throw new Error("Malformed JWT: expected at least 2 dot-separated segments.");
19
+ }
20
+ const payloadB64Url = parts[1];
21
+ const padLen = (4 - (payloadB64Url.length % 4)) % 4;
22
+ const base64 = payloadB64Url
23
+ .replace(/-/g, "+")
24
+ .replace(/_/g, "/")
25
+ .padEnd(payloadB64Url.length + padLen, "=");
26
+ return JSON.parse(atob(base64));
27
+ }
28
+ export function isJwtExpired(jwt, marginMs) {
29
+ try {
30
+ const { exp } = decodeJwtPayload(jwt);
31
+ if (typeof exp !== "number")
32
+ return true;
33
+ return Date.now() >= exp * 1000 - marginMs;
34
+ }
35
+ catch {
36
+ return true;
37
+ }
38
+ }
39
+ function bytesToHex(bytes) {
40
+ let hex = "";
41
+ for (let i = 0; i < bytes.length; i++) {
42
+ hex += bytes[i].toString(16).padStart(2, "0");
43
+ }
44
+ return hex;
45
+ }
46
+ function hasLeadingZeroBits(hash, bits) {
47
+ if (bits > hash.length * 8)
48
+ return false;
49
+ const fullBytes = Math.floor(bits / 8);
50
+ const remainingBits = bits % 8;
51
+ for (let i = 0; i < fullBytes; i++) {
52
+ if (hash[i] !== 0)
53
+ return false;
54
+ }
55
+ if (remainingBits > 0) {
56
+ const mask = (0xff << (8 - remainingBits)) & 0xff;
57
+ if ((hash[fullBytes] & mask) !== 0)
58
+ return false;
59
+ }
60
+ return true;
61
+ }
62
+ // auth-service passes the SCRYPT_SALT_HEX string directly as the `salt`
63
+ // argument to node:crypto's scrypt (i.e. the UTF-8 bytes of the hex string,
64
+ // NOT the decoded hex bytes). We mirror that exactly so client and server
65
+ // derive the same digest.
66
+ function scryptHash(data, salt) {
67
+ const bytes = new TextEncoder().encode(data);
68
+ return new Promise((resolve, reject) => {
69
+ scrypt(bytes, salt, POW_HASH_BYTES, SCRYPT_PARAMS, (err, derived) => {
70
+ if (err)
71
+ return reject(err);
72
+ resolve(new Uint8Array(derived));
73
+ });
74
+ });
75
+ }
76
+ export async function solvePow(challenge, difficulty, salt, onProgress) {
77
+ let nonce = 0n;
78
+ while (true) {
79
+ const digest = await scryptHash(`${challenge}:${nonce}`, salt);
80
+ if (hasLeadingZeroBits(digest, difficulty)) {
81
+ return { powHex: bytesToHex(digest), nonce: nonce.toString() };
82
+ }
83
+ nonce++;
84
+ if (onProgress && nonce % 64n === 0n)
85
+ onProgress(nonce);
86
+ }
87
+ }
88
+ async function postJson(url, body, headers = {}) {
89
+ const res = await fetch(url, {
90
+ method: "POST",
91
+ headers: {
92
+ ...(body ? { "Content-Type": "application/json" } : {}),
93
+ ...headers,
94
+ },
95
+ body: body ? JSON.stringify(body) : undefined,
96
+ });
97
+ const text = await res.text();
98
+ const path = (() => {
99
+ try {
100
+ return new URL(url).pathname;
101
+ }
102
+ catch {
103
+ return url;
104
+ }
105
+ })();
106
+ if (!res.ok) {
107
+ throw new Error(`auth-service ${path} returned ${res.status}: ${text}`);
108
+ }
109
+ try {
110
+ return JSON.parse(text);
111
+ }
112
+ catch {
113
+ throw new Error(`auth-service ${path} returned non-JSON body: ${text}`);
114
+ }
115
+ }
116
+ export async function fetchChallenge(authUrl) {
117
+ const data = await postJson(`${authUrl}/api/v1/challenge`, undefined);
118
+ if (typeof data.challengeJWT !== "string") {
119
+ throw new Error("Challenge response missing challengeJWT.");
120
+ }
121
+ const payload = decodeJwtPayload(data.challengeJWT);
122
+ if (typeof payload.jti !== "string" ||
123
+ typeof payload.difficulty !== "number") {
124
+ throw new Error("Challenge JWT payload malformed (missing jti or difficulty).");
125
+ }
126
+ return {
127
+ challengeJWT: data.challengeJWT,
128
+ challenge: payload.jti,
129
+ difficulty: payload.difficulty,
130
+ };
131
+ }
132
+ export async function exchangeSession(authUrl, body) {
133
+ const data = await postJson(`${authUrl}/api/v1/session`, { ...body });
134
+ if (typeof data.sessionJWT !== "string") {
135
+ throw new Error("Session response missing sessionJWT.");
136
+ }
137
+ return {
138
+ sessionJWT: data.sessionJWT,
139
+ apiKey: typeof data.apiKey === "string" ? data.apiKey : undefined,
140
+ };
141
+ }
142
+ export async function fetchCapability(authUrl, sessionJWT) {
143
+ const data = await postJson(`${authUrl}/api/v1/capability`, undefined, { Authorization: `Bearer ${sessionJWT}` });
144
+ if (typeof data.capabilityJWT !== "string") {
145
+ throw new Error("Capability response missing capabilityJWT.");
146
+ }
147
+ return data.capabilityJWT;
148
+ }
149
+ // One round trip of: /challenge -> solve PoW -> /session.
150
+ // `apiKey` (login) and `username` (signup) are mutually exclusive; the caller
151
+ // must enforce that before calling.
152
+ export async function performPoWAndSession(input) {
153
+ const { authUrl, scryptSalt } = input;
154
+ const { challengeJWT, challenge, difficulty } = await fetchChallenge(authUrl);
155
+ const { powHex, nonce } = await solvePow(challenge, difficulty, scryptSalt, input.onPowProgress);
156
+ return exchangeSession(authUrl, {
157
+ challengeJWT,
158
+ powHex,
159
+ nonce,
160
+ apiKey: input.apiKey,
161
+ username: input.username,
162
+ });
163
+ }
@@ -0,0 +1,18 @@
1
+ export interface Credentials {
2
+ apiKey: string;
3
+ inboxId: string;
4
+ authUrl: string;
5
+ apiUrl: string;
6
+ scryptSalt: string;
7
+ }
8
+ export interface SkillFiles {
9
+ credentialsFile: string;
10
+ sessionFile: string;
11
+ capabilityFile: string;
12
+ }
13
+ export declare function defaultFilesFromOutDir(outDir: string): SkillFiles;
14
+ export declare function writeCredentials(path: string, creds: Credentials): Promise<void>;
15
+ export declare function readCredentials(path: string): Promise<Credentials>;
16
+ export declare function writeJwtFile(path: string, jwt: string): Promise<void>;
17
+ export declare function tryReadJwtFile(path: string): Promise<string | undefined>;
18
+ //# sourceMappingURL=credentials.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"credentials.d.ts","sourceRoot":"","sources":["../../../../src/skill/scripts/lib/credentials.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,UAAU;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,WAAW,EAAE,MAAM,CAAC;IACpB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAOjE;AAMD,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,WAAW,GACjB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAiCxE;AAED,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAG3E;AAED,wBAAsB,cAAc,CAClC,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAO7B"}
@@ -0,0 +1,66 @@
1
+ // Credential file I/O. Three files form a complete skill credential set:
2
+ // credentials.json — long-lived: apiKey, inboxId, server URLs, PoW salt.
3
+ // session.jwt — short-lived (~4h): rotates via /session + PoW.
4
+ // capability.jwt — short-lived (~2min): rotates via /capability.
5
+ //
6
+ // Files are written with mode 0600 so other local users cannot read them.
7
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
8
+ import { dirname, join, resolve } from "node:path";
9
+ export function defaultFilesFromOutDir(outDir) {
10
+ const base = resolve(outDir);
11
+ return {
12
+ credentialsFile: join(base, "credentials.json"),
13
+ sessionFile: join(base, "session.jwt"),
14
+ capabilityFile: join(base, "capability.jwt"),
15
+ };
16
+ }
17
+ async function ensureParent(path) {
18
+ await mkdir(dirname(path), { recursive: true });
19
+ }
20
+ export async function writeCredentials(path, creds) {
21
+ await ensureParent(path);
22
+ await writeFile(path, JSON.stringify(creds, null, 2) + "\n", { mode: 0o600 });
23
+ }
24
+ export async function readCredentials(path) {
25
+ let raw;
26
+ try {
27
+ raw = await readFile(path, "utf-8");
28
+ }
29
+ catch (err) {
30
+ throw new Error(`Could not read credentials file '${path}': ${err.message}. ` +
31
+ "Did you run signup first?");
32
+ }
33
+ let obj;
34
+ try {
35
+ obj = JSON.parse(raw);
36
+ }
37
+ catch (err) {
38
+ throw new Error(`Credentials file '${path}' is not valid JSON: ${err.message}`);
39
+ }
40
+ const required = [
41
+ "apiKey",
42
+ "inboxId",
43
+ "authUrl",
44
+ "apiUrl",
45
+ "scryptSalt",
46
+ ];
47
+ for (const k of required) {
48
+ if (typeof obj[k] !== "string" || obj[k].length === 0) {
49
+ throw new Error(`Credentials file '${path}' missing required field: ${k}`);
50
+ }
51
+ }
52
+ return obj;
53
+ }
54
+ export async function writeJwtFile(path, jwt) {
55
+ await ensureParent(path);
56
+ await writeFile(path, jwt, { mode: 0o600 });
57
+ }
58
+ export async function tryReadJwtFile(path) {
59
+ try {
60
+ const raw = await readFile(path, "utf-8");
61
+ return raw.trim();
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ }
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=signup.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"signup.d.ts","sourceRoot":"","sources":["../../../src/skill/scripts/signup.ts"],"names":[],"mappings":""}
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ // Atomic Mail skill: signup / login.
3
+ //
4
+ // Performs the full PoW handshake against auth-service:
5
+ // POST /api/v1/challenge -> { challengeJWT }
6
+ // solve scrypt PoW
7
+ // POST /api/v1/session -> { sessionJWT, apiKey? }
8
+ // POST /api/v1/capability -> { capabilityJWT }
9
+ //
10
+ // Writes three files into --out-dir:
11
+ // credentials.json stable account info + server config
12
+ // session.jwt 4h TTL, rotated by jmap_request when expired
13
+ // capability.jwt 2m TTL, rotated by jmap_request before each request
14
+ import process from "node:process";
15
+ import { parseArgs } from "node:util";
16
+ import { DEFAULT_POW_SCRYPT_SALT_HEX } from "../../lib/src/consts.js";
17
+ import { decodeJwtPayload, fetchCapability, performPoWAndSession, } from "./lib/auth.js";
18
+ import { defaultFilesFromOutDir, writeCredentials, writeJwtFile, } from "./lib/credentials.js";
19
+ const HELP = `Usage: atomic-mail-signup [OPTIONS]
20
+
21
+ Register a new Atomic Mail account, or log in with an existing API key.
22
+ Persists credentials to disk so subsequent atomic-mail-jmap invocations
23
+ work without re-authentication.
24
+
25
+ Options:
26
+ --auth-url URL Auth-service base URL.
27
+ [env: ATOMIC_MAIL_AUTH_URL]
28
+ --api-url URL API-service base URL (stored for jmap_request).
29
+ [env: ATOMIC_MAIL_API_URL]
30
+ --scrypt-salt SALT Override PoW scrypt salt (defaults to the deployment
31
+ constant). [env: ATOMIC_MAIL_SCRYPT_SALT]
32
+ --username NAME Register a NEW account with this username. The server
33
+ returns a freshly minted API key. Mutually exclusive
34
+ with --api-key.
35
+ --api-key KEY Log in with an existing API key. Mutually exclusive
36
+ with --username.
37
+ --out-dir DIR Directory to write credential files into.
38
+ Default: current working directory.
39
+ --quiet Suppress progress messages on stderr.
40
+ --help, -h Show this help.
41
+
42
+ Output files (in --out-dir):
43
+ credentials.json { apiKey, inboxId, authUrl, apiUrl, scryptSalt }
44
+ session.jwt Session JWT (4h TTL).
45
+ capability.jwt Capability JWT (2m TTL).
46
+
47
+ Exit codes:
48
+ 0 success
49
+ 1 network error, server rejection, or unexpected response shape
50
+ 2 invalid CLI usage (missing/conflicting flags)
51
+
52
+ Examples:
53
+ atomic-mail-signup --auth-url https://auth.example.com \\
54
+ --api-url https://api.example.com \\
55
+ --username alice
56
+ atomic-mail-signup --api-key 11111111-2222-3333-4444-555555555555
57
+ `;
58
+ function fail(message, code = 1) {
59
+ process.stderr.write(`Error: ${message}\n`);
60
+ if (code === 2)
61
+ process.stderr.write("\nRun with --help for usage.\n");
62
+ process.exit(code);
63
+ }
64
+ function readArgs() {
65
+ let parsed;
66
+ try {
67
+ parsed = parseArgs({
68
+ args: process.argv.slice(2),
69
+ options: {
70
+ "auth-url": { type: "string" },
71
+ "api-url": { type: "string" },
72
+ "scrypt-salt": { type: "string" },
73
+ username: { type: "string" },
74
+ "api-key": { type: "string" },
75
+ "out-dir": { type: "string" },
76
+ quiet: { type: "boolean" },
77
+ help: { type: "boolean", short: "h" },
78
+ },
79
+ strict: true,
80
+ allowPositionals: false,
81
+ });
82
+ }
83
+ catch (err) {
84
+ fail(err.message, 2);
85
+ }
86
+ if (parsed.values.help) {
87
+ process.stdout.write(HELP);
88
+ process.exit(0);
89
+ }
90
+ const env = process.env;
91
+ const authUrl = parsed.values["auth-url"] ??
92
+ env.ATOMIC_MAIL_AUTH_URL ?? "";
93
+ const apiUrl = parsed.values["api-url"] ??
94
+ env.ATOMIC_MAIL_API_URL ?? "";
95
+ const scryptSalt = parsed.values["scrypt-salt"] ??
96
+ env.ATOMIC_MAIL_SCRYPT_SALT ?? DEFAULT_POW_SCRYPT_SALT_HEX;
97
+ const outDir = parsed.values["out-dir"] ?? ".";
98
+ if (!authUrl) {
99
+ fail("--auth-url is required (or set ATOMIC_MAIL_AUTH_URL).", 2);
100
+ }
101
+ if (!apiUrl) {
102
+ fail("--api-url is required (or set ATOMIC_MAIL_API_URL).", 2);
103
+ }
104
+ const username = parsed.values.username;
105
+ const apiKey = parsed.values["api-key"];
106
+ if (!!username === !!apiKey) {
107
+ fail("Provide exactly one of --username (new account) or --api-key (login).", 2);
108
+ }
109
+ return {
110
+ authUrl: authUrl.replace(/\/+$/, ""),
111
+ apiUrl: apiUrl.replace(/\/+$/, ""),
112
+ scryptSalt,
113
+ username,
114
+ apiKey,
115
+ outDir,
116
+ quiet: parsed.values.quiet === true,
117
+ };
118
+ }
119
+ async function main() {
120
+ const args = readArgs();
121
+ const files = defaultFilesFromOutDir(args.outDir);
122
+ const log = (msg) => {
123
+ if (!args.quiet)
124
+ process.stderr.write(msg + "\n");
125
+ };
126
+ log(args.username
127
+ ? `Registering new account "${args.username}"...`
128
+ : `Logging in with existing API key...`);
129
+ log(`Solving PoW + exchanging session against ${args.authUrl}...`);
130
+ const session = await performPoWAndSession({
131
+ authUrl: args.authUrl,
132
+ scryptSalt: args.scryptSalt,
133
+ username: args.username,
134
+ apiKey: args.apiKey,
135
+ });
136
+ const apiKey = session.apiKey ?? args.apiKey;
137
+ if (!apiKey) {
138
+ fail("Session response did not include an apiKey and none was provided. " +
139
+ "This indicates a server bug.");
140
+ }
141
+ log(`Fetching initial capability JWT...`);
142
+ const capabilityJWT = await fetchCapability(args.authUrl, session.sessionJWT);
143
+ const claims = decodeJwtPayload(capabilityJWT);
144
+ const inboxId = claims.inboxId;
145
+ if (typeof inboxId !== "string" || inboxId.length === 0) {
146
+ fail("Capability JWT did not contain an inboxId claim.");
147
+ }
148
+ await writeCredentials(files.credentialsFile, {
149
+ apiKey,
150
+ inboxId,
151
+ authUrl: args.authUrl,
152
+ apiUrl: args.apiUrl,
153
+ scryptSalt: args.scryptSalt,
154
+ });
155
+ await writeJwtFile(files.sessionFile, session.sessionJWT);
156
+ await writeJwtFile(files.capabilityFile, capabilityJWT);
157
+ log(`Wrote ${files.credentialsFile}`);
158
+ log(`Wrote ${files.sessionFile}`);
159
+ log(`Wrote ${files.capabilityFile}`);
160
+ process.stdout.write(JSON.stringify({
161
+ inboxId,
162
+ apiKey,
163
+ credentialsFile: files.credentialsFile,
164
+ sessionFile: files.sessionFile,
165
+ capabilityFile: files.capabilityFile,
166
+ }, null, 2) + "\n");
167
+ }
168
+ main().catch((err) => {
169
+ fail(err instanceof Error ? err.message : String(err));
170
+ });
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@atomicmail/agent-skill",
3
+ "version": "0.1.0",
4
+ "description": "Atomic Mail agent skill — PoW signup + JMAP request CLIs for AI agents.",
5
+ "keywords": [
6
+ "atomic-mail",
7
+ "atomicmail",
8
+ "agentskills",
9
+ "agent",
10
+ "ai",
11
+ "jmap",
12
+ "esp",
13
+ "email",
14
+ "mcp",
15
+ "proof-of-work"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/atomic-mail/agentic-mail.git"
20
+ },
21
+ "license": "MIT",
22
+ "bugs": {
23
+ "url": "https://github.com/atomic-mail/agentic-mail/issues"
24
+ },
25
+ "scripts": {},
26
+ "bin": {
27
+ "atomic-mail-signup": "./esm/skill/scripts/signup.js",
28
+ "atomic-mail-jmap": "./esm/skill/scripts/jmap_request.js"
29
+ },
30
+ "engines": {
31
+ "node": ">=20"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "devDependencies": {
37
+ "@types/node": "^20.12.0"
38
+ },
39
+ "_generatedBy": "dnt@dev"
40
+ }