@hartewired/gmail-cli 1.0.0 → 1.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/README.md +46 -7
- package/bin/gmail.js +21 -7
- package/lib/config.js +79 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# gmail-cli
|
|
2
2
|
|
|
3
|
-
[](https://github.com/harteWired/gmail-cli/actions/workflows/publish.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@hartewired/gmail-cli)
|
|
5
|
+
[](https://nodejs.org)
|
|
5
6
|
[](./LICENSE)
|
|
7
|
+
[](https://github.com/harteWired)
|
|
6
8
|
|
|
7
9
|
A stateless, zero-dependency, **agent-friendly** Gmail CLI. Read, send, reply, forward, label, and organize mail straight from the shell — with JSON output on every command.
|
|
8
10
|
|
|
@@ -13,6 +15,10 @@ gmail reply <id> --all --body "Thanks — got it."
|
|
|
13
15
|
gmail markread --query "is:unread older_than:30d"
|
|
14
16
|
```
|
|
15
17
|
|
|
18
|
+
<p align="center">
|
|
19
|
+
<img src="https://raw.githubusercontent.com/harteWired/gmail-cli/main/docs/architecture.svg" width="820" alt="Stateless architecture: a caller shells out to gmail, which resolves credentials, mints or reuses a cached access token, calls the Gmail REST API, prints JSON, and exits. No daemon runs when idle.">
|
|
20
|
+
</p>
|
|
21
|
+
|
|
16
22
|
## Why another Gmail CLI
|
|
17
23
|
|
|
18
24
|
Most Gmail tools are built for a human sitting at a terminal. This one is built to be **driven by a program** — an LLM agent, a cron job, a shell script:
|
|
@@ -73,6 +79,27 @@ is just a convenience wrapper around this.
|
|
|
73
79
|
|
|
74
80
|
Scopes requested: `gmail.modify`, `gmail.compose`, `gmail.send`.
|
|
75
81
|
|
|
82
|
+
## Multiple accounts
|
|
83
|
+
|
|
84
|
+
Every command takes `--account NAME` to target a specific inbox. Authorize each one once:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
gmail auth --account personal
|
|
88
|
+
gmail auth --account work
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Then target them per command:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
gmail list --account work "is:unread"
|
|
95
|
+
gmail send --account personal --to a@b.com --subject Hi --body "hey"
|
|
96
|
+
gmail accounts # list configured accounts + the default
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The first account you authorize becomes the default (used whenever `--account`
|
|
100
|
+
is omitted). Each account's credentials and token cache are stored separately.
|
|
101
|
+
A single-account setup never needs `--account` at all.
|
|
102
|
+
|
|
76
103
|
## Commands
|
|
77
104
|
|
|
78
105
|
Read:
|
|
@@ -111,13 +138,25 @@ Run `gmail help` for the full list.
|
|
|
111
138
|
|
|
112
139
|
## Configuration reference
|
|
113
140
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
141
|
+
The config file (`$GMAIL_CLI_CONFIG`, default `~/.config/gmail-cli/config.json`)
|
|
142
|
+
holds named accounts:
|
|
143
|
+
|
|
144
|
+
```json
|
|
145
|
+
{
|
|
146
|
+
"default": "personal",
|
|
147
|
+
"accounts": {
|
|
148
|
+
"personal": { "client_id": "…", "client_secret": "…", "refresh_token": "…" },
|
|
149
|
+
"work": { "client_id": "…", "client_secret": "…", "refresh_token": "…" }
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Env vars override the active account per-field (handy for single-account CI):
|
|
155
|
+
`GMAIL_CLI_CLIENT_ID`, `GMAIL_CLI_CLIENT_SECRET`, `GMAIL_CLI_REFRESH_TOKEN`.
|
|
118
156
|
|
|
119
|
-
|
|
120
|
-
|
|
157
|
+
A legacy flat file (`{ "client_id", "client_secret", "refresh_token" }`) is still
|
|
158
|
+
read as the account named `default`. Per-account token caches live at
|
|
159
|
+
`~/.config/gmail-cli/token-<account>.json`.
|
|
121
160
|
|
|
122
161
|
## Development
|
|
123
162
|
|
package/bin/gmail.js
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
listAttachments, base64urlDecode, guessMimeType, parseAddrs,
|
|
14
14
|
} from '../lib/mime.js';
|
|
15
15
|
import { authorize } from '../lib/oauth.js';
|
|
16
|
-
import { resolveCreds,
|
|
16
|
+
import { resolveCreds, saveAccount, setAccount, activeAccount, listAccounts, configPath } from '../lib/config.js';
|
|
17
17
|
|
|
18
18
|
const asArray = (v) => (v === undefined ? [] : [].concat(v));
|
|
19
19
|
const out = (obj) => console.log(JSON.stringify(obj, null, 2));
|
|
@@ -84,15 +84,24 @@ async function applyLabels(ids, addLabelIds, removeLabelIds) {
|
|
|
84
84
|
|
|
85
85
|
// --- commands ---------------------------------------------------------------
|
|
86
86
|
const commands = {
|
|
87
|
-
// auth [--client-id X] [--client-secret Y] [--manual]
|
|
87
|
+
// auth [--account NAME] [--client-id X] [--client-secret Y] [--manual]
|
|
88
88
|
async auth(pos, flags) {
|
|
89
|
-
|
|
90
|
-
|
|
89
|
+
const account = flags.account || activeAccount();
|
|
90
|
+
setAccount(account);
|
|
91
|
+
const patch = {};
|
|
92
|
+
if (flags['client-id']) patch.client_id = flags['client-id'];
|
|
93
|
+
if (flags['client-secret']) patch.client_secret = flags['client-secret'];
|
|
94
|
+
if (Object.keys(patch).length) saveAccount(account, patch);
|
|
91
95
|
const { clientId, clientSecret } = resolveCreds({ requireToken: false });
|
|
92
96
|
const refreshToken = await authorize({ clientId, clientSecret, manual: !!flags.manual });
|
|
93
|
-
|
|
97
|
+
saveAccount(account, { refresh_token: refreshToken });
|
|
94
98
|
const me = await gmail('GET', '/profile');
|
|
95
|
-
out({ authenticated: true, email: me.emailAddress, config: configPath() });
|
|
99
|
+
out({ authenticated: true, account, email: me.emailAddress, config: configPath() });
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
// accounts — list configured accounts and the active/default one.
|
|
103
|
+
accounts() {
|
|
104
|
+
out({ active: activeAccount(), accounts: listAccounts() });
|
|
96
105
|
},
|
|
97
106
|
|
|
98
107
|
async profile() { out(await gmail('GET', '/profile')); },
|
|
@@ -359,8 +368,12 @@ const commands = {
|
|
|
359
368
|
console.log(`gmail — stateless, zero-dep Gmail CLI
|
|
360
369
|
|
|
361
370
|
Setup:
|
|
362
|
-
gmail auth [--manual] [--client-id X --client-secret Y]
|
|
371
|
+
gmail auth [--account NAME] [--manual] [--client-id X --client-secret Y]
|
|
363
372
|
one-time OAuth sign-in
|
|
373
|
+
gmail accounts list configured accounts
|
|
374
|
+
|
|
375
|
+
Any command takes --account NAME to target a specific inbox (default: the
|
|
376
|
+
configured "default" account).
|
|
364
377
|
|
|
365
378
|
Read:
|
|
366
379
|
profile | labels
|
|
@@ -398,6 +411,7 @@ async function main() {
|
|
|
398
411
|
process.exit(cmd && !command ? 1 : 0);
|
|
399
412
|
}
|
|
400
413
|
const { positional, flags } = parseArgs(rest);
|
|
414
|
+
if (flags.account) setAccount(flags.account); // global: select which account this command uses
|
|
401
415
|
try {
|
|
402
416
|
await command(positional, flags);
|
|
403
417
|
} catch (err) {
|
package/lib/config.js
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
// Configuration + credential resolution for gmail-cli.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// 1. Environment variables (GMAIL_CLI_CLIENT_ID, _CLIENT_SECRET, _REFRESH_TOKEN)
|
|
6
|
-
// 2. Config file JSON (default ~/.config/gmail-cli/config.json, override with
|
|
7
|
-
// $GMAIL_CLI_CONFIG)
|
|
3
|
+
// Multi-account. The config file (default ~/.config/gmail-cli/config.json,
|
|
4
|
+
// override with $GMAIL_CLI_CONFIG) holds named accounts:
|
|
8
5
|
//
|
|
9
|
-
//
|
|
6
|
+
// {
|
|
7
|
+
// "default": "personal",
|
|
8
|
+
// "accounts": {
|
|
9
|
+
// "personal": { "client_id": "...", "client_secret": "...", "refresh_token": "..." },
|
|
10
|
+
// "work": { "client_id": "...", "client_secret": "...", "refresh_token": "..." }
|
|
11
|
+
// }
|
|
12
|
+
// }
|
|
13
|
+
//
|
|
14
|
+
// A legacy flat file ({ "client_id", "client_secret", "refresh_token" }) is
|
|
15
|
+
// still honored and treated as the account named "default".
|
|
16
|
+
//
|
|
17
|
+
// The active account is picked by (in order): the --account flag, the config
|
|
18
|
+
// "default", then "default". Env vars (GMAIL_CLI_CLIENT_ID / _CLIENT_SECRET /
|
|
19
|
+
// _REFRESH_TOKEN) override per-field for whichever account is active — handy
|
|
20
|
+
// for single-account CI.
|
|
10
21
|
|
|
11
22
|
import { homedir } from 'node:os';
|
|
12
23
|
import { join } from 'node:path';
|
|
@@ -14,7 +25,7 @@ import {
|
|
|
14
25
|
readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync,
|
|
15
26
|
} from 'node:fs';
|
|
16
27
|
|
|
17
|
-
//
|
|
28
|
+
// --- paths (lazy: read env at call time, so tests/overrides work) -----------
|
|
18
29
|
export function configDir() {
|
|
19
30
|
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
20
31
|
return join(xdg, 'gmail-cli');
|
|
@@ -22,8 +33,9 @@ export function configDir() {
|
|
|
22
33
|
export function configPath() {
|
|
23
34
|
return process.env.GMAIL_CLI_CONFIG || join(configDir(), 'config.json');
|
|
24
35
|
}
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
const sanitize = (name) => name.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
37
|
+
export function tokenPath(account) {
|
|
38
|
+
return join(configDir(), `token-${sanitize(account || activeAccount())}.json`);
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
function ensureDir() {
|
|
@@ -38,39 +50,81 @@ export function loadConfig() {
|
|
|
38
50
|
}
|
|
39
51
|
}
|
|
40
52
|
|
|
41
|
-
//
|
|
42
|
-
|
|
53
|
+
// --- active-account selection -----------------------------------------------
|
|
54
|
+
let _account = null;
|
|
55
|
+
export function setAccount(name) { _account = name || null; }
|
|
56
|
+
|
|
57
|
+
export function activeAccount() {
|
|
58
|
+
if (_account) return _account;
|
|
59
|
+
const cfg = loadConfig();
|
|
60
|
+
return cfg.default || 'default';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function listAccounts() {
|
|
64
|
+
const cfg = loadConfig();
|
|
65
|
+
if (cfg.accounts) return Object.keys(cfg.accounts);
|
|
66
|
+
if (cfg.client_id || cfg.refresh_token) return ['default']; // legacy flat
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Per-account stored credentials from the file (no env), or {} if none.
|
|
71
|
+
function fileCredsFor(name) {
|
|
72
|
+
const cfg = loadConfig();
|
|
73
|
+
if (cfg.accounts?.[name]) return cfg.accounts[name];
|
|
74
|
+
// Legacy flat file counts as the "default" account.
|
|
75
|
+
if (!cfg.accounts && name === 'default' && (cfg.client_id || cfg.refresh_token)) {
|
|
76
|
+
return { client_id: cfg.client_id, client_secret: cfg.client_secret, refresh_token: cfg.refresh_token };
|
|
77
|
+
}
|
|
78
|
+
return {};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Write credentials for a named account, migrating a legacy flat file into the
|
|
82
|
+
// accounts map on first multi-account write. Sets `default` if unset.
|
|
83
|
+
export function saveAccount(name, creds) {
|
|
43
84
|
ensureDir();
|
|
44
|
-
const
|
|
85
|
+
const cfg = loadConfig();
|
|
86
|
+
if (!cfg.accounts) {
|
|
87
|
+
cfg.accounts = {};
|
|
88
|
+
if (cfg.client_id || cfg.refresh_token) {
|
|
89
|
+
cfg.accounts.default = { client_id: cfg.client_id, client_secret: cfg.client_secret, refresh_token: cfg.refresh_token };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
delete cfg.client_id; delete cfg.client_secret; delete cfg.refresh_token;
|
|
93
|
+
cfg.accounts[name] = { ...cfg.accounts[name], ...creds };
|
|
94
|
+
if (!cfg.default) cfg.default = name;
|
|
45
95
|
const path = configPath();
|
|
46
|
-
writeFileSync(path, JSON.stringify(
|
|
96
|
+
writeFileSync(path, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
47
97
|
if (existsSync(path)) chmodSync(path, 0o600);
|
|
48
|
-
return
|
|
98
|
+
return cfg;
|
|
49
99
|
}
|
|
50
100
|
|
|
51
|
-
//
|
|
52
|
-
//
|
|
101
|
+
// --- credential resolution --------------------------------------------------
|
|
102
|
+
// Resolve { account, clientId, clientSecret, refreshToken } for the active
|
|
103
|
+
// account. `requireToken=false` skips the refresh-token requirement (used by
|
|
104
|
+
// `gmail auth`, which is minting one).
|
|
53
105
|
export function resolveCreds({ requireToken = true } = {}) {
|
|
54
|
-
const
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
const
|
|
106
|
+
const account = activeAccount();
|
|
107
|
+
const fc = fileCredsFor(account);
|
|
108
|
+
const clientId = process.env.GMAIL_CLI_CLIENT_ID || fc.client_id;
|
|
109
|
+
const clientSecret = process.env.GMAIL_CLI_CLIENT_SECRET || fc.client_secret;
|
|
110
|
+
const refreshToken = process.env.GMAIL_CLI_REFRESH_TOKEN || fc.refresh_token;
|
|
58
111
|
|
|
59
112
|
if (!clientId || !clientSecret) {
|
|
60
113
|
throw new Error(
|
|
61
|
-
|
|
62
|
-
`or
|
|
114
|
+
`no OAuth client for account "${account}". Set GMAIL_CLI_CLIENT_ID / GMAIL_CLI_CLIENT_SECRET, ` +
|
|
115
|
+
`or run \`gmail auth --account ${account} --client-id … --client-secret …\`. See README "Setup".`
|
|
63
116
|
);
|
|
64
117
|
}
|
|
65
118
|
if (requireToken && !refreshToken) {
|
|
66
119
|
throw new Error(
|
|
67
|
-
|
|
68
|
-
'See README "Authentication".'
|
|
120
|
+
`account "${account}" is not authenticated. Run \`gmail auth --account ${account}\`, ` +
|
|
121
|
+
'or set GMAIL_CLI_REFRESH_TOKEN. See README "Authentication".'
|
|
69
122
|
);
|
|
70
123
|
}
|
|
71
|
-
return { clientId, clientSecret, refreshToken };
|
|
124
|
+
return { account, clientId, clientSecret, refreshToken };
|
|
72
125
|
}
|
|
73
126
|
|
|
127
|
+
// --- token cache ------------------------------------------------------------
|
|
74
128
|
export function readTokenCache() {
|
|
75
129
|
try {
|
|
76
130
|
return JSON.parse(readFileSync(tokenPath(), 'utf8'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hartewired/gmail-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A stateless, zero-dependency, agent-friendly Gmail CLI. Read, send, reply, label, and organize mail from the shell — no daemon, JSON output on every command.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|