@atomicmail/agent-skill 0.1.0 → 0.2.1
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/README.md +91 -0
- package/SKILL.md +53 -193
- package/esm/_dnt.polyfills.d.ts +101 -0
- package/esm/_dnt.polyfills.d.ts.map +1 -0
- package/esm/_dnt.polyfills.js +127 -0
- package/esm/{skill/scripts/lib/auth.d.ts → lib/agent/auth/agent-auth-http.d.ts} +1 -17
- package/esm/lib/agent/auth/agent-auth-http.d.ts.map +1 -0
- package/esm/lib/agent/auth/agent-auth-http.js +76 -0
- package/esm/lib/agent/auth/agent-jwt.d.ts +14 -0
- package/esm/lib/agent/auth/agent-jwt.d.ts.map +1 -0
- package/esm/lib/agent/auth/agent-jwt.js +29 -0
- package/esm/lib/agent/auth/agent-pow.d.ts +5 -0
- package/esm/lib/agent/auth/agent-pow.d.ts.map +1 -0
- package/esm/lib/agent/auth/agent-pow.js +49 -0
- package/esm/lib/agent/jmap/agent-help-content.d.ts +4 -0
- package/esm/lib/agent/jmap/agent-help-content.d.ts.map +1 -0
- package/esm/lib/agent/jmap/agent-help-content.js +244 -0
- package/esm/lib/agent/jmap/agent-jmap.d.ts +49 -0
- package/esm/lib/agent/jmap/agent-jmap.d.ts.map +1 -0
- package/esm/lib/agent/jmap/agent-jmap.js +174 -0
- package/esm/lib/agent/jmap/agent-vars.d.ts +23 -0
- package/esm/lib/agent/jmap/agent-vars.d.ts.map +1 -0
- package/esm/lib/agent/jmap/agent-vars.js +65 -0
- package/esm/{skill/scripts/lib/credentials.d.ts → lib/agent/session/agent-credentials-store.d.ts} +4 -1
- package/esm/lib/agent/session/agent-credentials-store.d.ts.map +1 -0
- package/esm/{skill/scripts/lib/credentials.js → lib/agent/session/agent-credentials-store.js} +28 -8
- package/esm/lib/agent/session/agent-resolve-config.d.ts +24 -0
- package/esm/lib/agent/session/agent-resolve-config.d.ts.map +1 -0
- package/esm/lib/agent/session/agent-resolve-config.js +70 -0
- package/esm/lib/agent/session/agent-session.d.ts +62 -0
- package/esm/lib/agent/session/agent-session.d.ts.map +1 -0
- package/esm/lib/agent/session/agent-session.js +206 -0
- package/esm/lib/core/consts.d.ts.map +1 -0
- package/esm/lib/core/types.d.ts +2 -0
- package/esm/lib/core/types.d.ts.map +1 -0
- package/esm/lib/core/types.js +1 -0
- package/esm/lib/core/utils.d.ts +10 -0
- package/esm/lib/core/utils.d.ts.map +1 -0
- package/esm/lib/core/utils.js +28 -0
- package/esm/lib/mod.d.ts +14 -0
- package/esm/lib/mod.d.ts.map +1 -0
- package/esm/lib/mod.js +13 -0
- package/esm/lib/network/auth-client.d.ts +57 -0
- package/esm/lib/network/auth-client.d.ts.map +1 -0
- package/esm/lib/network/auth-client.js +188 -0
- package/esm/skill/cli.d.ts +3 -0
- package/esm/skill/cli.d.ts.map +1 -0
- package/esm/skill/cli.js +306 -0
- package/package.json +5 -6
- package/presets/list_inbox.json +39 -0
- package/presets/reply.json +75 -0
- package/presets/send_mail.json +42 -0
- package/esm/lib/src/consts.d.ts.map +0 -1
- package/esm/skill/scripts/jmap_request.d.ts +0 -3
- package/esm/skill/scripts/jmap_request.d.ts.map +0 -1
- package/esm/skill/scripts/jmap_request.js +0 -265
- package/esm/skill/scripts/lib/auth.d.ts.map +0 -1
- package/esm/skill/scripts/lib/auth.js +0 -163
- package/esm/skill/scripts/lib/credentials.d.ts.map +0 -1
- package/esm/skill/scripts/signup.d.ts +0 -3
- package/esm/skill/scripts/signup.d.ts.map +0 -1
- package/esm/skill/scripts/signup.js +0 -170
- /package/esm/lib/{src → core}/consts.d.ts +0 -0
- /package/esm/lib/{src → core}/consts.js +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface AuthClientOptions {
|
|
2
|
+
/** Base URL of auth-service, e.g. "http://localhost:8000". Trailing slashes are stripped. */
|
|
3
|
+
baseUrl: string;
|
|
4
|
+
/**
|
|
5
|
+
* PoW scrypt salt (hex string). When omitted, {@link DEFAULT_POW_SCRYPT_SALT_HEX}
|
|
6
|
+
* is used so clients match the bundled auth-service.
|
|
7
|
+
*/
|
|
8
|
+
scryptSaltHex?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface SignupResult {
|
|
11
|
+
/** Freshly minted API key. The server only returns it once — persist it. */
|
|
12
|
+
apiKey: string;
|
|
13
|
+
sessionJWT: string;
|
|
14
|
+
}
|
|
15
|
+
export interface LoginResult {
|
|
16
|
+
sessionJWT: string;
|
|
17
|
+
}
|
|
18
|
+
export interface RenewResult {
|
|
19
|
+
capabilityJWT: string;
|
|
20
|
+
}
|
|
21
|
+
/** Thrown for any non-2xx HTTP response or malformed payload. */
|
|
22
|
+
export declare class AuthClientError extends Error {
|
|
23
|
+
status: number;
|
|
24
|
+
bodyText: string;
|
|
25
|
+
constructor(status: number, bodyText: string, message: string);
|
|
26
|
+
}
|
|
27
|
+
export declare class AuthClient {
|
|
28
|
+
private readonly baseUrl;
|
|
29
|
+
private readonly scryptSaltHex;
|
|
30
|
+
constructor(options: AuthClientOptions);
|
|
31
|
+
/**
|
|
32
|
+
* Register a new inbox under `username`. Returns the freshly minted API key
|
|
33
|
+
* (the server only ever returns it once — the caller MUST persist it) and
|
|
34
|
+
* a session JWT.
|
|
35
|
+
*/
|
|
36
|
+
signup(username: string): Promise<SignupResult>;
|
|
37
|
+
/** Exchange an existing API key for a fresh session JWT. */
|
|
38
|
+
login(apiKey: string): Promise<LoginResult>;
|
|
39
|
+
/**
|
|
40
|
+
* Exchange a session JWT for a short-lived capability JWT (audience:
|
|
41
|
+
* api-service).
|
|
42
|
+
*/
|
|
43
|
+
renew(sessionJWT: string): Promise<RenewResult>;
|
|
44
|
+
private fetchChallenge;
|
|
45
|
+
private postSession;
|
|
46
|
+
private parseJsonOrThrow;
|
|
47
|
+
/**
|
|
48
|
+
* Brute-force a PoW nonce. Mirrors `generatePow` in
|
|
49
|
+
* services/auth-service/src/crypto.ts: scrypt(`${challenge}:${nonce}`, salt,
|
|
50
|
+
* 64) until `difficulty` leading bits of the digest are zero.
|
|
51
|
+
*
|
|
52
|
+
* Expected work at the server's POW_DIFFICULTY=6 is ~2^6 = 64 attempts; well
|
|
53
|
+
* within the challenge JWT's 3-minute TTL.
|
|
54
|
+
*/
|
|
55
|
+
private solvePoW;
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=auth-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"auth-client.d.ts","sourceRoot":"","sources":["../../../src/lib/network/auth-client.ts"],"names":[],"mappings":"AAoBA,MAAM,WAAW,iBAAiB;IAChC,6FAA6F;IAC7F,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,4EAA4E;IAC5E,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,WAAW;IAC1B,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,iEAAiE;AACjE,qBAAa,eAAgB,SAAQ,KAAK;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;gBAEL,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM;CAM9D;AAOD,qBAAa,UAAU;IACrB,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAS;IACjC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;gBAE3B,OAAO,EAAE,iBAAiB;IAKtC;;;;OAIG;IACG,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;IAwBrD,4DAA4D;IACtD,KAAK,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;IAqBjD;;;OAGG;IACG,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC;YAgBvC,cAAc;YAkCd,WAAW;YAeX,gBAAgB;IAuB9B;;;;;;;OAOG;YACW,QAAQ;CAgBvB"}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// auth-client
|
|
2
|
+
//
|
|
3
|
+
// Thin HTTP client for services/auth-service. Encapsulates the full PoW
|
|
4
|
+
// challenge → session → capability flow so callers (integration tests, the
|
|
5
|
+
// future agent skill, etc.) don't have to reimplement scrypt grinding.
|
|
6
|
+
//
|
|
7
|
+
// The PoW digest is scrypt-based and uses the SAME salt the auth-service
|
|
8
|
+
// uses on the verify path (see services/auth-service/src/crypto.ts). The
|
|
9
|
+
// client must therefore be configured with that salt — there is no public
|
|
10
|
+
// hash function here, the salt is part of the protocol.
|
|
11
|
+
import { scrypt } from "node:crypto";
|
|
12
|
+
import { DEFAULT_POW_SCRYPT_SALT_HEX } from "../core/consts.js";
|
|
13
|
+
// Mirror services/auth-service/src/crypto.ts exactly. Changing any of these
|
|
14
|
+
// constants on either side breaks PoW interop.
|
|
15
|
+
const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1 };
|
|
16
|
+
const POW_HASH_BYTES = 64;
|
|
17
|
+
/** Thrown for any non-2xx HTTP response or malformed payload. */
|
|
18
|
+
export class AuthClientError extends Error {
|
|
19
|
+
status;
|
|
20
|
+
bodyText;
|
|
21
|
+
constructor(status, bodyText, message) {
|
|
22
|
+
super(message);
|
|
23
|
+
this.name = "AuthClientError";
|
|
24
|
+
this.status = status;
|
|
25
|
+
this.bodyText = bodyText;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export class AuthClient {
|
|
29
|
+
baseUrl;
|
|
30
|
+
scryptSaltHex;
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
33
|
+
this.scryptSaltHex = options.scryptSaltHex ?? DEFAULT_POW_SCRYPT_SALT_HEX;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Register a new inbox under `username`. Returns the freshly minted API key
|
|
37
|
+
* (the server only ever returns it once — the caller MUST persist it) and
|
|
38
|
+
* a session JWT.
|
|
39
|
+
*/
|
|
40
|
+
async signup(username) {
|
|
41
|
+
const { challengeJWT, challenge, difficulty } = await this.fetchChallenge();
|
|
42
|
+
const { powHex, nonce } = await this.solvePoW(challenge, difficulty);
|
|
43
|
+
const data = await this.postSession({
|
|
44
|
+
challengeJWT,
|
|
45
|
+
powHex,
|
|
46
|
+
nonce: nonce.toString(),
|
|
47
|
+
username,
|
|
48
|
+
});
|
|
49
|
+
if (typeof data.apiKey !== "string" ||
|
|
50
|
+
typeof data.sessionJWT !== "string") {
|
|
51
|
+
throw new AuthClientError(200, JSON.stringify(data), "Signup response missing apiKey or sessionJWT.");
|
|
52
|
+
}
|
|
53
|
+
return { apiKey: data.apiKey, sessionJWT: data.sessionJWT };
|
|
54
|
+
}
|
|
55
|
+
/** Exchange an existing API key for a fresh session JWT. */
|
|
56
|
+
async login(apiKey) {
|
|
57
|
+
const { challengeJWT, challenge, difficulty } = await this.fetchChallenge();
|
|
58
|
+
const { powHex, nonce } = await this.solvePoW(challenge, difficulty);
|
|
59
|
+
const data = await this.postSession({
|
|
60
|
+
challengeJWT,
|
|
61
|
+
powHex,
|
|
62
|
+
nonce: nonce.toString(),
|
|
63
|
+
apiKey,
|
|
64
|
+
});
|
|
65
|
+
if (typeof data.sessionJWT !== "string") {
|
|
66
|
+
throw new AuthClientError(200, JSON.stringify(data), "Login response missing sessionJWT.");
|
|
67
|
+
}
|
|
68
|
+
return { sessionJWT: data.sessionJWT };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Exchange a session JWT for a short-lived capability JWT (audience:
|
|
72
|
+
* api-service).
|
|
73
|
+
*/
|
|
74
|
+
async renew(sessionJWT) {
|
|
75
|
+
const res = await fetch(`${this.baseUrl}/api/v1/capability`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { Authorization: `Bearer ${sessionJWT}` },
|
|
78
|
+
});
|
|
79
|
+
const data = await this.parseJsonOrThrow(res, "capability");
|
|
80
|
+
if (typeof data.capabilityJWT !== "string") {
|
|
81
|
+
throw new AuthClientError(res.status, JSON.stringify(data), "Capability response missing capabilityJWT.");
|
|
82
|
+
}
|
|
83
|
+
return { capabilityJWT: data.capabilityJWT };
|
|
84
|
+
}
|
|
85
|
+
async fetchChallenge() {
|
|
86
|
+
const res = await fetch(`${this.baseUrl}/api/v1/challenge`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
});
|
|
89
|
+
const data = await this.parseJsonOrThrow(res, "challenge");
|
|
90
|
+
if (typeof data.challengeJWT !== "string") {
|
|
91
|
+
throw new AuthClientError(res.status, JSON.stringify(data), "Challenge response missing challengeJWT.");
|
|
92
|
+
}
|
|
93
|
+
const payload = decodeJwtPayload(data.challengeJWT);
|
|
94
|
+
if (typeof payload.jti !== "string" ||
|
|
95
|
+
typeof payload.difficulty !== "number") {
|
|
96
|
+
throw new AuthClientError(res.status, data.challengeJWT, "Challenge JWT payload is malformed (missing jti or difficulty).");
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
challengeJWT: data.challengeJWT,
|
|
100
|
+
challenge: payload.jti,
|
|
101
|
+
difficulty: payload.difficulty,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
async postSession(body) {
|
|
105
|
+
const res = await fetch(`${this.baseUrl}/api/v1/session`, {
|
|
106
|
+
method: "POST",
|
|
107
|
+
headers: { "Content-Type": "application/json" },
|
|
108
|
+
body: JSON.stringify(body),
|
|
109
|
+
});
|
|
110
|
+
return await this.parseJsonOrThrow(res, "session");
|
|
111
|
+
}
|
|
112
|
+
async parseJsonOrThrow(res, endpoint) {
|
|
113
|
+
const text = await res.text();
|
|
114
|
+
if (!res.ok) {
|
|
115
|
+
throw new AuthClientError(res.status, text, `auth-service ${endpoint} returned ${res.status}: ${text}`);
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return JSON.parse(text);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
throw new AuthClientError(res.status, text, `auth-service ${endpoint} returned non-JSON body.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Brute-force a PoW nonce. Mirrors `generatePow` in
|
|
126
|
+
* services/auth-service/src/crypto.ts: scrypt(`${challenge}:${nonce}`, salt,
|
|
127
|
+
* 64) until `difficulty` leading bits of the digest are zero.
|
|
128
|
+
*
|
|
129
|
+
* Expected work at the server's POW_DIFFICULTY=6 is ~2^6 = 64 attempts; well
|
|
130
|
+
* within the challenge JWT's 3-minute TTL.
|
|
131
|
+
*/
|
|
132
|
+
async solvePoW(challenge, difficulty) {
|
|
133
|
+
let nonce = 0n;
|
|
134
|
+
while (true) {
|
|
135
|
+
const digest = await scryptHash(`${challenge}:${nonce}`, this.scryptSaltHex);
|
|
136
|
+
if (hasLeadingZeroBits(digest, difficulty)) {
|
|
137
|
+
return { powHex: bytesToHex(digest), nonce };
|
|
138
|
+
}
|
|
139
|
+
nonce++;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
function scryptHash(data, salt) {
|
|
144
|
+
const bytes = new TextEncoder().encode(data);
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
scrypt(bytes, salt, POW_HASH_BYTES, SCRYPT_PARAMS, (err, derived) => {
|
|
147
|
+
if (err)
|
|
148
|
+
return reject(err);
|
|
149
|
+
resolve(new Uint8Array(derived));
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function hasLeadingZeroBits(hash, bits) {
|
|
154
|
+
if (bits > hash.length * 8)
|
|
155
|
+
return false;
|
|
156
|
+
const fullBytes = Math.floor(bits / 8);
|
|
157
|
+
const remainingBits = bits % 8;
|
|
158
|
+
for (let i = 0; i < fullBytes; i++) {
|
|
159
|
+
if (hash[i] !== 0)
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
if (remainingBits > 0) {
|
|
163
|
+
const mask = (0xff << (8 - remainingBits)) & 0xff;
|
|
164
|
+
if ((hash[fullBytes] & mask) !== 0)
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
return true;
|
|
168
|
+
}
|
|
169
|
+
function bytesToHex(bytes) {
|
|
170
|
+
let hex = "";
|
|
171
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
172
|
+
hex += bytes[i].toString(16).padStart(2, "0");
|
|
173
|
+
}
|
|
174
|
+
return hex;
|
|
175
|
+
}
|
|
176
|
+
function decodeJwtPayload(jwt) {
|
|
177
|
+
const parts = jwt.split(".");
|
|
178
|
+
if (parts.length < 2) {
|
|
179
|
+
throw new Error("Malformed JWT: expected at least 2 dot-separated segments.");
|
|
180
|
+
}
|
|
181
|
+
const payloadB64Url = parts[1];
|
|
182
|
+
const padLen = (4 - (payloadB64Url.length % 4)) % 4;
|
|
183
|
+
const base64 = payloadB64Url
|
|
184
|
+
.replace(/-/g, "+")
|
|
185
|
+
.replace(/_/g, "/")
|
|
186
|
+
.padEnd(payloadB64Url.length + padLen, "=");
|
|
187
|
+
return JSON.parse(atob(base64));
|
|
188
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/skill/cli.ts"],"names":[],"mappings":";AAEA,OAAO,sBAAsB,CAAC"}
|
package/esm/skill/cli.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Atomic Mail AgentSkill — register | jmap_request | help
|
|
3
|
+
import "../_dnt.polyfills.js";
|
|
4
|
+
import process from "node:process";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { resolve } from "node:path";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { AgentSession, DEFAULT_JMAP_USING, DEFAULT_POW_SCRYPT_SALT_HEX, defaultFilesFromOutDir, getHelp, persistLoginWithApiKey, readCredentials, readOpsFile, runJmapRequest, } from "../lib/mod.js";
|
|
9
|
+
const USAGE = `Atomic Mail — AgentSkill
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
atomicmail <command> [options]
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
register PoW signup or login with API key (writes credentials)
|
|
16
|
+
jmap_request Send a JMAP batch (inline --ops or --ops-file preset)
|
|
17
|
+
help Full documentation [--topic TOPIC]
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
atomicmail register --username alice
|
|
21
|
+
atomicmail register --api-key UUID
|
|
22
|
+
atomicmail jmap_request --credentials-dir ./.atomic-mail --ops-file fetch.json
|
|
23
|
+
atomicmail jmap_request --credentials-dir ./.atomic-mail --ops-file send.json --vars '{"TO":"a@b.com","SUBJECT":"Hi"}'
|
|
24
|
+
atomicmail help --topic presets
|
|
25
|
+
|
|
26
|
+
Run atomicmail <command> --help for command-specific flags.
|
|
27
|
+
`;
|
|
28
|
+
function exitUsage(code = 0) {
|
|
29
|
+
process.stdout.write(USAGE);
|
|
30
|
+
process.exit(code);
|
|
31
|
+
}
|
|
32
|
+
function fail(message, code = 1) {
|
|
33
|
+
process.stderr.write(`Error: ${message}\n`);
|
|
34
|
+
process.exit(code);
|
|
35
|
+
}
|
|
36
|
+
function resolveCredentialDir(dir) {
|
|
37
|
+
const raw = dir ?? process.env.ATOMIC_MAIL_CREDENTIALS_DIR ?? "~/.atomicmail";
|
|
38
|
+
if (raw === "~")
|
|
39
|
+
return homedir();
|
|
40
|
+
return resolve(raw.replace(/^~\//, `${homedir()}/`));
|
|
41
|
+
}
|
|
42
|
+
async function cmdRegister(argv) {
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = parseArgs({
|
|
46
|
+
args: argv,
|
|
47
|
+
options: {
|
|
48
|
+
"auth-url": { type: "string" },
|
|
49
|
+
"api-url": { type: "string" },
|
|
50
|
+
"scrypt-salt": { type: "string" },
|
|
51
|
+
username: { type: "string" },
|
|
52
|
+
"api-key": { type: "string" },
|
|
53
|
+
"credentials-dir": { type: "string" },
|
|
54
|
+
quiet: { type: "boolean" },
|
|
55
|
+
help: { type: "boolean", short: "h" },
|
|
56
|
+
},
|
|
57
|
+
strict: true,
|
|
58
|
+
allowPositionals: false,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
fail(err.message, 2);
|
|
63
|
+
}
|
|
64
|
+
if (parsed.values.help) {
|
|
65
|
+
process.stdout.write(`Usage: atomicmail register [OPTIONS]
|
|
66
|
+
|
|
67
|
+
Register a new inbox (--username) or log in with an existing API key (--api-key).
|
|
68
|
+
|
|
69
|
+
Options:
|
|
70
|
+
--auth-url URL Auth-service base URL [env: ATOMIC_MAIL_AUTH_URL, default: https://auth.atomicmail.ai]
|
|
71
|
+
--api-url URL API / JMAP base URL [env: ATOMIC_MAIL_API_URL, default: https://api.atomicmail.ai]
|
|
72
|
+
--scrypt-salt SALT PoW salt override [env: ATOMIC_MAIL_SCRYPT_SALT]
|
|
73
|
+
--username NAME New account (mutually exclusive with --api-key)
|
|
74
|
+
--api-key KEY Existing API key (mutually exclusive with --username)
|
|
75
|
+
--credentials-dir DIR Credential directory (default: ~/.atomicmail)
|
|
76
|
+
--quiet Less stderr output
|
|
77
|
+
--help, -h This message
|
|
78
|
+
`);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
}
|
|
81
|
+
const env = process.env;
|
|
82
|
+
const authUrl = parsed.values["auth-url"] ??
|
|
83
|
+
env.ATOMIC_MAIL_AUTH_URL ?? "https://auth.atomicmail.ai";
|
|
84
|
+
const apiUrl = parsed.values["api-url"] ??
|
|
85
|
+
env.ATOMIC_MAIL_API_URL ?? "https://api.atomicmail.ai";
|
|
86
|
+
const scryptSalt = parsed.values["scrypt-salt"] ??
|
|
87
|
+
env.ATOMIC_MAIL_SCRYPT_SALT ?? DEFAULT_POW_SCRYPT_SALT_HEX;
|
|
88
|
+
const dir = parsed.values["credentials-dir"];
|
|
89
|
+
const credentialDir = resolveCredentialDir(dir);
|
|
90
|
+
const username = parsed.values.username;
|
|
91
|
+
const apiKey = parsed.values["api-key"];
|
|
92
|
+
if (!!username === !!apiKey) {
|
|
93
|
+
fail("Provide exactly one of --username (new account) or --api-key (login).", 2);
|
|
94
|
+
}
|
|
95
|
+
const files = defaultFilesFromOutDir(credentialDir);
|
|
96
|
+
const log = (msg) => {
|
|
97
|
+
if (!parsed.values.quiet)
|
|
98
|
+
process.stderr.write(msg + "\n");
|
|
99
|
+
};
|
|
100
|
+
if (username) {
|
|
101
|
+
log(`Registering "${username}"...`);
|
|
102
|
+
const session = await AgentSession.create({
|
|
103
|
+
authUrl,
|
|
104
|
+
apiUrl,
|
|
105
|
+
scryptSalt,
|
|
106
|
+
credentialDir,
|
|
107
|
+
files,
|
|
108
|
+
});
|
|
109
|
+
const result = await session.register(username);
|
|
110
|
+
log(`Wrote credentials under ${credentialDir}`);
|
|
111
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
log("Logging in with API key...");
|
|
115
|
+
const { inboxId } = await persistLoginWithApiKey({
|
|
116
|
+
authUrl,
|
|
117
|
+
apiUrl,
|
|
118
|
+
scryptSalt,
|
|
119
|
+
apiKey: apiKey,
|
|
120
|
+
files,
|
|
121
|
+
});
|
|
122
|
+
log(`Wrote ${files.credentialsFile}`);
|
|
123
|
+
process.stdout.write(JSON.stringify({ inboxId }, null, 2) + "\n");
|
|
124
|
+
}
|
|
125
|
+
async function cmdJmapRequest(argv) {
|
|
126
|
+
let parsed;
|
|
127
|
+
try {
|
|
128
|
+
parsed = parseArgs({
|
|
129
|
+
args: argv,
|
|
130
|
+
options: {
|
|
131
|
+
"credentials-dir": { type: "string" },
|
|
132
|
+
"credentials-file": { type: "string" },
|
|
133
|
+
"session-file": { type: "string" },
|
|
134
|
+
"capability-file": { type: "string" },
|
|
135
|
+
ops: { type: "string" },
|
|
136
|
+
"ops-file": { type: "string" },
|
|
137
|
+
using: { type: "string" },
|
|
138
|
+
"dry-run": { type: "boolean" },
|
|
139
|
+
vars: { type: "string" },
|
|
140
|
+
help: { type: "boolean", short: "h" },
|
|
141
|
+
},
|
|
142
|
+
strict: true,
|
|
143
|
+
allowPositionals: false,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
fail(err.message, 2);
|
|
148
|
+
}
|
|
149
|
+
if (parsed.values.help) {
|
|
150
|
+
process.stdout.write(`Usage: atomicmail jmap_request [OPTIONS]
|
|
151
|
+
|
|
152
|
+
Send a JMAP request using saved credentials.
|
|
153
|
+
|
|
154
|
+
Options:
|
|
155
|
+
--credentials-dir DIR Directory with credentials.json + JWTs (default: ~/.atomicmail)
|
|
156
|
+
--credentials-file PATH Override credentials.json path
|
|
157
|
+
--session-file PATH Override session.jwt path
|
|
158
|
+
--capability-file PATH Override capability.jwt path
|
|
159
|
+
--ops JSON Inline JMAP JSON (methodCalls or envelope)
|
|
160
|
+
--ops-file PATH Preset file ($VAR_NAME placeholders supported)
|
|
161
|
+
--vars JSON JSON object { VAR_NAME: string } for $VAR_NAME in ops / ops-file
|
|
162
|
+
--using LIST Comma-separated capability URNs (optional)
|
|
163
|
+
--dry-run Print resolved request only
|
|
164
|
+
--help, -h This message
|
|
165
|
+
`);
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
const dir = parsed.values["credentials-dir"];
|
|
169
|
+
const credentialDir = resolveCredentialDir(dir);
|
|
170
|
+
const defaults = defaultFilesFromOutDir(credentialDir);
|
|
171
|
+
const credentialsFile = parsed.values["credentials-file"] ??
|
|
172
|
+
defaults.credentialsFile;
|
|
173
|
+
const sessionFile = parsed.values["session-file"] ??
|
|
174
|
+
defaults.sessionFile;
|
|
175
|
+
const capabilityFile = parsed.values["capability-file"] ??
|
|
176
|
+
defaults.capabilityFile;
|
|
177
|
+
const ops = parsed.values.ops;
|
|
178
|
+
const opsFile = parsed.values["ops-file"];
|
|
179
|
+
if (ops && opsFile) {
|
|
180
|
+
fail("--ops and --ops-file are mutually exclusive.", 2);
|
|
181
|
+
}
|
|
182
|
+
if (!ops && !opsFile) {
|
|
183
|
+
fail("Provide --ops or --ops-file.", 2);
|
|
184
|
+
}
|
|
185
|
+
const usingFlag = parsed.values.using;
|
|
186
|
+
const defaultUsing = usingFlag
|
|
187
|
+
? usingFlag.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
|
|
188
|
+
: [...DEFAULT_JMAP_USING];
|
|
189
|
+
let userVars;
|
|
190
|
+
const varsFlag = parsed.values.vars;
|
|
191
|
+
if (varsFlag !== undefined) {
|
|
192
|
+
let obj;
|
|
193
|
+
try {
|
|
194
|
+
obj = JSON.parse(varsFlag);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
fail(`--vars is not valid JSON: ${err.message}`, 2);
|
|
198
|
+
}
|
|
199
|
+
if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
|
|
200
|
+
fail("--vars must be a JSON object of { VAR_NAME: string }.", 2);
|
|
201
|
+
}
|
|
202
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
203
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(k)) {
|
|
204
|
+
fail(`--vars key '${k}' must match /^[A-Z][A-Z0-9_]*$/.`, 2);
|
|
205
|
+
}
|
|
206
|
+
if (typeof v !== "string") {
|
|
207
|
+
fail(`--vars value for '${k}' must be a string.`, 2);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
userVars = obj;
|
|
211
|
+
}
|
|
212
|
+
const creds = await readCredentials(credentialsFile);
|
|
213
|
+
const files = {
|
|
214
|
+
credentialsFile,
|
|
215
|
+
sessionFile,
|
|
216
|
+
capabilityFile,
|
|
217
|
+
};
|
|
218
|
+
const session = await AgentSession.create({
|
|
219
|
+
authUrl: creds.authUrl,
|
|
220
|
+
apiUrl: creds.apiUrl,
|
|
221
|
+
scryptSalt: creds.scryptSalt,
|
|
222
|
+
apiKey: creds.apiKey,
|
|
223
|
+
inboxId: creds.inboxId,
|
|
224
|
+
credentialDir,
|
|
225
|
+
files,
|
|
226
|
+
});
|
|
227
|
+
let raw;
|
|
228
|
+
let sourceLabel;
|
|
229
|
+
if (opsFile) {
|
|
230
|
+
try {
|
|
231
|
+
raw = await readOpsFile(credentialDir, opsFile);
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
fail(`Could not read --ops-file: ${err.message}`, 2);
|
|
235
|
+
}
|
|
236
|
+
sourceLabel = opsFile;
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
raw = ops;
|
|
240
|
+
sourceLabel = "ops";
|
|
241
|
+
}
|
|
242
|
+
const { ok, status, bodyText } = await runJmapRequest({
|
|
243
|
+
session,
|
|
244
|
+
opsJson: raw,
|
|
245
|
+
defaultUsing,
|
|
246
|
+
sourceLabel,
|
|
247
|
+
dryRun: parsed.values["dry-run"] === true,
|
|
248
|
+
vars: userVars,
|
|
249
|
+
});
|
|
250
|
+
if (!ok) {
|
|
251
|
+
fail(`JMAP request failed (HTTP ${status}): ${bodyText}`, 1);
|
|
252
|
+
}
|
|
253
|
+
process.stdout.write(bodyText.endsWith("\n") ? bodyText : bodyText + "\n");
|
|
254
|
+
}
|
|
255
|
+
function cmdHelp(argv) {
|
|
256
|
+
let parsed;
|
|
257
|
+
try {
|
|
258
|
+
parsed = parseArgs({
|
|
259
|
+
args: argv,
|
|
260
|
+
options: {
|
|
261
|
+
topic: { type: "string" },
|
|
262
|
+
help: { type: "boolean", short: "h" },
|
|
263
|
+
},
|
|
264
|
+
strict: true,
|
|
265
|
+
allowPositionals: false,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
fail(err.message, 2);
|
|
270
|
+
}
|
|
271
|
+
if (parsed.values.help) {
|
|
272
|
+
process.stdout.write(`Usage: atomicmail help [--topic TOPIC]
|
|
273
|
+
|
|
274
|
+
Topics include: overview, installation, auth, jmap_cheatsheet, tools, presets, troubleshooting.
|
|
275
|
+
`);
|
|
276
|
+
process.exit(0);
|
|
277
|
+
}
|
|
278
|
+
const topic = parsed.values.topic;
|
|
279
|
+
process.stdout.write(getHelp(topic) + "\n");
|
|
280
|
+
}
|
|
281
|
+
async function main() {
|
|
282
|
+
const argv = process.argv.slice(2);
|
|
283
|
+
if (argv.length === 0 || argv[0] === "-h" || argv[0] === "--help") {
|
|
284
|
+
exitUsage(0);
|
|
285
|
+
}
|
|
286
|
+
const cmd = argv[0];
|
|
287
|
+
const rest = argv.slice(1);
|
|
288
|
+
switch (cmd) {
|
|
289
|
+
case "register":
|
|
290
|
+
await cmdRegister(rest);
|
|
291
|
+
break;
|
|
292
|
+
case "jmap_request":
|
|
293
|
+
await cmdJmapRequest(rest);
|
|
294
|
+
break;
|
|
295
|
+
case "help":
|
|
296
|
+
cmdHelp(rest);
|
|
297
|
+
break;
|
|
298
|
+
default:
|
|
299
|
+
process.stderr.write(`Unknown command: ${cmd}\n\n`);
|
|
300
|
+
process.stdout.write(USAGE);
|
|
301
|
+
process.exit(2);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
main().catch((err) => {
|
|
305
|
+
fail(err instanceof Error ? err.message : String(err));
|
|
306
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@atomicmail/agent-skill",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "Atomic Mail
|
|
3
|
+
"version": "0.2.1",
|
|
4
|
+
"description": "Atomic Mail AgentSkill — register, jmap_request, and help CLI for AI agents.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"atomic-mail",
|
|
7
7
|
"atomicmail",
|
|
@@ -16,16 +16,15 @@
|
|
|
16
16
|
],
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
19
|
-
"url": "git+https://github.com/atomic-mail/agentic-
|
|
19
|
+
"url": "git+https://github.com/atomic-mail/agentic-clients.git"
|
|
20
20
|
},
|
|
21
21
|
"license": "MIT",
|
|
22
22
|
"bugs": {
|
|
23
|
-
"url": "https://github.com/atomic-mail/agentic-
|
|
23
|
+
"url": "https://github.com/atomic-mail/agentic-clients/issues"
|
|
24
24
|
},
|
|
25
25
|
"scripts": {},
|
|
26
26
|
"bin": {
|
|
27
|
-
"
|
|
28
|
-
"atomic-mail-jmap": "./esm/skill/scripts/jmap_request.js"
|
|
27
|
+
"atomicmail": "./esm/skill/cli.js"
|
|
29
28
|
},
|
|
30
29
|
"engines": {
|
|
31
30
|
"node": ">=20"
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"using": [
|
|
3
|
+
"urn:ietf:params:jmap:core",
|
|
4
|
+
"urn:ietf:params:jmap:mail"
|
|
5
|
+
],
|
|
6
|
+
"methodCalls": [
|
|
7
|
+
[
|
|
8
|
+
"Email/query",
|
|
9
|
+
{
|
|
10
|
+
"accountId": "$ACCOUNT_ID",
|
|
11
|
+
"filter": { "inMailbox": "$INBOX" },
|
|
12
|
+
"sort": [{ "property": "receivedAt", "isAscending": false }],
|
|
13
|
+
"limit": "$COUNT"
|
|
14
|
+
},
|
|
15
|
+
"q0"
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
"Email/get",
|
|
19
|
+
{
|
|
20
|
+
"accountId": "$ACCOUNT_ID",
|
|
21
|
+
"#ids": {
|
|
22
|
+
"resultOf": "q0",
|
|
23
|
+
"name": "Email/query",
|
|
24
|
+
"path": "/ids"
|
|
25
|
+
},
|
|
26
|
+
"properties": [
|
|
27
|
+
"id",
|
|
28
|
+
"threadId",
|
|
29
|
+
"receivedAt",
|
|
30
|
+
"from",
|
|
31
|
+
"to",
|
|
32
|
+
"subject",
|
|
33
|
+
"preview"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"g0"
|
|
37
|
+
]
|
|
38
|
+
]
|
|
39
|
+
}
|