mitsupi 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +24 -0
  3. package/README.md +28 -18
  4. package/analyze-edits.py +232 -0
  5. package/distributions/README.md +8 -0
  6. package/distributions/mitsupi-common/README.md +11 -0
  7. package/distributions/mitsupi-common/package.json +95 -0
  8. package/distributions/mitsupi-loaded/README.md +13 -0
  9. package/distributions/mitsupi-loaded/package.json +38 -0
  10. package/{pi-extensions → extensions}/answer.ts +11 -11
  11. package/extensions/btw.ts +963 -0
  12. package/{pi-extensions → extensions}/context.ts +2 -2
  13. package/{pi-extensions → extensions}/control.ts +20 -13
  14. package/{pi-extensions → extensions}/files.ts +6 -8
  15. package/{pi-extensions → extensions}/go-to-bed.ts +2 -2
  16. package/{pi-extensions → extensions}/loop.ts +18 -11
  17. package/extensions/multi-edit.ts +871 -0
  18. package/{pi-extensions → extensions}/review.ts +254 -82
  19. package/{pi-extensions → extensions}/session-breakdown.ts +551 -74
  20. package/extensions/split-fork.ts +130 -0
  21. package/{pi-extensions → extensions}/todos.ts +41 -34
  22. package/extensions/uv.ts +123 -0
  23. package/intercepted-commands/poetry +8 -1
  24. package/intercepted-commands/python +42 -2
  25. package/intercepted-commands/python3 +42 -2
  26. package/package.json +6 -3
  27. package/skills/google-workspace/SKILL.md +53 -8
  28. package/skills/google-workspace/scripts/auth.js +106 -28
  29. package/skills/google-workspace/scripts/common.js +201 -32
  30. package/skills/google-workspace/scripts/workspace.js +64 -9
  31. package/skills/native-web-search/SKILL.md +2 -0
  32. package/skills/native-web-search/search.mjs +102 -15
  33. package/skills/pi-share/SKILL.md +5 -2
  34. package/skills/pi-share/fetch-session.mjs +46 -15
  35. package/skills/summarize/SKILL.md +4 -3
  36. package/skills/summarize/to-markdown.mjs +3 -1
  37. package/skills/web-browser/SKILL.md +6 -0
  38. package/skills/web-browser/scripts/start.js +75 -27
  39. package/pi-extensions/uv.ts +0 -33
  40. /package/{pi-extensions → extensions}/notify.ts +0 -0
  41. /package/{pi-extensions → extensions}/prompt-editor.ts +0 -0
  42. /package/{pi-extensions → extensions}/whimsical.ts +0 -0
  43. /package/{pi-themes → themes}/nightowl.json +0 -0
@@ -9,15 +9,29 @@ Use this skill for Google Workspace tasks (Gmail, Drive, Calendar, Docs, Sheets,
9
9
 
10
10
  ## Files
11
11
 
12
- - `scripts/auth.js` — OAuth login/status/clear
12
+ - `scripts/auth.js` — OAuth login/status/clear + account enumeration
13
13
  - `scripts/workspace.js` — JavaScript execution based API runner
14
14
 
15
+ ## Account model (multi-account)
16
+
17
+ This skill is **profile-based by email address**.
18
+
19
+ - There is **no default account**.
20
+ - Every API call must specify `--email <account@example.com>`.
21
+ - Tokens are stored per-email under `~/.pi/google-workspace/tokens/`.
22
+
23
+ Before running API calls, discover available signed-in accounts:
24
+
25
+ ```bash
26
+ node scripts/auth.js accounts
27
+ ```
28
+
15
29
  ## Usage
16
30
 
17
- Always use `exec`.
31
+ Always use `exec` and always provide `--email`.
18
32
 
19
33
  ```bash
20
- node scripts/workspace.js exec <<'JS'
34
+ node scripts/workspace.js exec --email user@example.com <<'JS'
21
35
  const me = await workspace.whoAmI();
22
36
  const files = await workspace.call('drive', 'files.list', {
23
37
  pageSize: 5,
@@ -31,6 +45,7 @@ Available inside exec scripts:
31
45
 
32
46
  - `auth` (authorized OAuth client)
33
47
  - `google` (`googleapis` root)
48
+ - `workspace.accountEmail` (selected profile email)
34
49
  - `workspace.call(service, methodPath, params, {version})`
35
50
  - `workspace.service(service, {version})`
36
51
  - `workspace.whoAmI()`
@@ -47,12 +62,41 @@ Optional flags:
47
62
  2. Keep payloads small (`fields`, `maxResults`, minimal props).
48
63
  3. Use `Promise.all` for independent requests.
49
64
  4. Never print token contents.
50
- 5. Use `scripts/auth.js` if you get auth errors.
65
+ 5. If the user did not specify an account, run `node scripts/auth.js accounts` and choose/confirm an explicit email.
66
+ 6. If auth fails, first run `node scripts/auth.js accounts` to see known profiles.
67
+ 7. If account mismatch is possible, run `workspace.whoAmI()` in the selected profile.
68
+ 8. On 401/403/unauthorized errors, switch account (`--email ...`) or re-login that specific profile.
69
+
70
+ ## Unauthorized/account-switch playbook
71
+
72
+ If a request fails with unauthorized/forbidden/insufficient permissions:
73
+
74
+ 1. Enumerate profiles:
75
+
76
+ ```bash
77
+ node scripts/auth.js accounts
78
+ ```
79
+
80
+ 2. Retry with the intended account:
81
+
82
+ ```bash
83
+ node scripts/workspace.js exec --email correct-user@example.com <<'JS'
84
+ return await workspace.whoAmI();
85
+ JS
86
+ ```
87
+
88
+ 3. If token is stale or missing scopes, re-login that account:
89
+
90
+ ```bash
91
+ node scripts/auth.js login --email correct-user@example.com
92
+ ```
93
+
94
+ 4. Retry the original request with the same `--email`.
51
95
 
52
96
  ## Short Gmail counting example
53
97
 
54
98
  ```bash
55
- node scripts/workspace.js exec <<'JS'
99
+ node scripts/workspace.js exec --email user@example.com <<'JS'
56
100
  const gmail = google.gmail({ version: 'v1', auth });
57
101
 
58
102
  let trash = 0;
@@ -76,7 +120,7 @@ JS
76
120
  ## Setup + auth
77
121
 
78
122
  ```bash
79
- node scripts/auth.js login
123
+ node scripts/auth.js login --email user@example.com
80
124
  ```
81
125
 
82
126
  Notes:
@@ -87,6 +131,7 @@ Notes:
87
131
  - Useful diagnostics:
88
132
 
89
133
  ```bash
90
- node scripts/auth.js status
91
- node scripts/auth.js clear
134
+ node scripts/auth.js accounts
135
+ node scripts/auth.js status --email user@example.com
136
+ node scripts/auth.js clear --email user@example.com
92
137
  ```
@@ -2,29 +2,33 @@
2
2
 
3
3
  const {
4
4
  CREDENTIALS_PATH,
5
- TOKEN_PATH,
5
+ TOKENS_DIR,
6
6
  DEFAULT_SCOPES,
7
7
  authorize,
8
8
  clearToken,
9
9
  credentialsExist,
10
10
  formatScopes,
11
11
  getWorkspaceClientConfig,
12
+ listAccounts,
12
13
  loadToken,
14
+ normalizeEmail,
13
15
  resolveAuthMode,
16
+ tokenPathForEmail,
14
17
  } = require('./common');
15
18
 
16
19
  function printHelp() {
17
20
  console.log(`Google Workspace auth helper
18
21
 
19
22
  Usage:
20
- node scripts/auth.js login [--scopes scope1,scope2,...]
21
- node scripts/auth.js status
22
- node scripts/auth.js clear
23
+ node scripts/auth.js login --email user@example.com [--scopes scope1,scope2,...]
24
+ node scripts/auth.js status --email user@example.com
25
+ node scripts/auth.js clear --email user@example.com
26
+ node scripts/auth.js accounts
23
27
 
24
28
  Environment overrides:
25
29
  GOOGLE_WORKSPACE_CONFIG_DIR
26
30
  GOOGLE_WORKSPACE_CREDENTIALS
27
- GOOGLE_WORKSPACE_TOKEN
31
+ GOOGLE_WORKSPACE_TOKENS_DIR
28
32
  GOOGLE_WORKSPACE_AUTH_MODE (local|cloud)
29
33
  GOOGLE_WORKSPACE_CLIENT_ID (cloud mode)
30
34
  GOOGLE_WORKSPACE_CLOUD_FUNCTION_URL (cloud mode)
@@ -36,10 +40,12 @@ function parseScopes(args) {
36
40
  if (idx === -1) {
37
41
  return DEFAULT_SCOPES;
38
42
  }
43
+
39
44
  const raw = args[idx + 1];
40
45
  if (!raw) {
41
46
  throw new Error('--scopes requires a value');
42
47
  }
48
+
43
49
  const scopes = formatScopes(raw);
44
50
  if (scopes.length === 0) {
45
51
  throw new Error('--scopes produced an empty scope list');
@@ -47,22 +53,63 @@ function parseScopes(args) {
47
53
  return scopes;
48
54
  }
49
55
 
56
+ function parseRequiredEmail(args) {
57
+ const idx = args.indexOf('--email');
58
+ if (idx === -1) {
59
+ throw new Error('Missing required --email <account@example.com>');
60
+ }
61
+
62
+ const raw = args[idx + 1];
63
+ if (!raw) {
64
+ throw new Error('--email requires a value');
65
+ }
66
+
67
+ return normalizeEmail(raw);
68
+ }
69
+
50
70
  async function doLogin(args) {
71
+ const email = parseRequiredEmail(args);
51
72
  const scopes = parseScopes(args);
52
- await authorize({ scopes, interactive: true });
73
+
74
+ await authorize({ email, scopes, interactive: true });
75
+
53
76
  console.log('✅ Login successful. Token stored at:');
54
- console.log(` ${TOKEN_PATH}`);
77
+ console.log(` ${tokenPathForEmail(email)}`);
78
+ }
79
+
80
+ function printTokenDetails(token) {
81
+ const now = Date.now();
82
+ const expiry = token.expiry_date || null;
83
+ const expired = expiry ? expiry < now : null;
84
+ const scopeCount = formatScopes(token.scope).length;
85
+
86
+ console.log('Token details:');
87
+ console.log(` access_token: ${token.access_token ? 'present' : 'missing'}`);
88
+ console.log(` refresh_token: ${token.refresh_token ? 'present' : 'missing'}`);
89
+ console.log(` scopes: ${scopeCount}`);
90
+
91
+ if (expiry) {
92
+ console.log(` expiry_date: ${new Date(expiry).toISOString()}`);
93
+ console.log(` expired: ${expired ? 'yes' : 'no'}`);
94
+ } else {
95
+ console.log(' expiry_date: n/a');
96
+ }
55
97
  }
56
98
 
57
- function doStatus() {
99
+ function doStatus(args) {
100
+ const email = parseRequiredEmail(args);
101
+
58
102
  console.log('Credentials file:');
59
103
  console.log(` ${CREDENTIALS_PATH}`);
60
104
  console.log(` Exists: ${credentialsExist() ? 'yes' : 'no'}`);
61
105
 
62
- const token = loadToken();
106
+ const token = loadToken(email);
63
107
  const mode = resolveAuthMode(token);
64
108
  const workspaceCfg = getWorkspaceClientConfig();
65
109
 
110
+ console.log('\nSelected account:');
111
+ console.log(` ${email}`);
112
+
66
113
  console.log('\nAuth mode:');
67
114
  console.log(` ${mode}`);
68
115
  if (mode === 'cloud') {
@@ -72,34 +119,60 @@ function doStatus() {
72
119
  }
73
120
 
74
121
  console.log('\nToken file:');
75
- console.log(` ${TOKEN_PATH}`);
122
+ console.log(` ${tokenPathForEmail(email)}`);
76
123
  console.log(` Exists: ${token ? 'yes' : 'no'}`);
77
124
 
78
125
  if (!token) {
126
+ console.log('\nTip: sign in with:');
127
+ console.log(` node scripts/auth.js login --email ${email}`);
79
128
  return;
80
129
  }
81
130
 
82
- const now = Date.now();
83
- const expiry = token.expiry_date || null;
84
- const expired = expiry ? expiry < now : null;
85
- const scopeCount = formatScopes(token.scope).length;
131
+ console.log('');
132
+ printTokenDetails(token);
133
+ }
86
134
 
87
- console.log('\nToken details:');
88
- console.log(` access_token: ${token.access_token ? 'present' : 'missing'}`);
89
- console.log(` refresh_token: ${token.refresh_token ? 'present' : 'missing'}`);
90
- console.log(` scopes: ${scopeCount}`);
135
+ function doAccounts() {
136
+ const accounts = listAccounts();
91
137
 
92
- if (expiry) {
93
- console.log(` expiry_date: ${new Date(expiry).toISOString()}`);
94
- console.log(` expired: ${expired ? 'yes' : 'no'}`);
95
- } else {
96
- console.log(' expiry_date: n/a');
138
+ console.log('Token store:');
139
+ console.log(` ${TOKENS_DIR}`);
140
+
141
+ if (accounts.length === 0) {
142
+ console.log('\nNo signed-in accounts found.');
143
+ return;
144
+ }
145
+
146
+ console.log(`\nSigned-in accounts (${accounts.length}):`);
147
+
148
+ for (const account of accounts) {
149
+ console.log('');
150
+ console.log(`- email: ${account.email || '(unknown)'}`);
151
+ console.log(` tokenFile: ${account.path}`);
152
+
153
+ if (account.error) {
154
+ console.log(` error: ${account.error}`);
155
+ continue;
156
+ }
157
+
158
+ console.log(` authMode: ${account.authMode || 'unknown'}`);
159
+ console.log(` access_token: ${account.hasAccessToken ? 'present' : 'missing'}`);
160
+ console.log(` refresh_token: ${account.hasRefreshToken ? 'present' : 'missing'}`);
161
+ console.log(` scopes: ${account.scopes.length}`);
162
+
163
+ if (account.expiryDate) {
164
+ console.log(` expiry_date: ${new Date(account.expiryDate).toISOString()}`);
165
+ console.log(` expired: ${account.expired ? 'yes' : 'no'}`);
166
+ } else {
167
+ console.log(' expiry_date: n/a');
168
+ }
97
169
  }
98
170
  }
99
171
 
100
- function doClear() {
101
- clearToken();
102
- console.log('✅ Token cleared.');
172
+ function doClear(args) {
173
+ const email = parseRequiredEmail(args);
174
+ clearToken(email);
175
+ console.log(`✅ Token cleared for ${email}.`);
103
176
  }
104
177
 
105
178
  async function main() {
@@ -116,12 +189,17 @@ async function main() {
116
189
  }
117
190
 
118
191
  if (command === 'status') {
119
- doStatus();
192
+ doStatus(args);
193
+ return;
194
+ }
195
+
196
+ if (command === 'accounts') {
197
+ doAccounts();
120
198
  return;
121
199
  }
122
200
 
123
201
  if (command === 'clear') {
124
- doClear();
202
+ doClear(args);
125
203
  return;
126
204
  }
127
205
 
@@ -16,8 +16,8 @@ const CREDENTIALS_PATH =
16
16
  process.env.GOOGLE_WORKSPACE_CREDENTIALS ||
17
17
  path.join(CONFIG_DIR, 'credentials.json');
18
18
 
19
- const TOKEN_PATH =
20
- process.env.GOOGLE_WORKSPACE_TOKEN || path.join(CONFIG_DIR, 'token.json');
19
+ const TOKENS_DIR =
20
+ process.env.GOOGLE_WORKSPACE_TOKENS_DIR || path.join(CONFIG_DIR, 'tokens');
21
21
 
22
22
  const SKILL_ROOT = path.join(__dirname, '..');
23
23
 
@@ -26,6 +26,11 @@ const DEFAULT_CLIENT_ID =
26
26
  const DEFAULT_CLOUD_FUNCTION_URL =
27
27
  'https://google-workspace-extension.geminicli.com';
28
28
 
29
+ const REQUIRED_IDENTITY_SCOPES = [
30
+ 'https://www.googleapis.com/auth/userinfo.email',
31
+ 'https://www.googleapis.com/auth/userinfo.profile',
32
+ ];
33
+
29
34
  const DEFAULT_SCOPES = [
30
35
  'https://www.googleapis.com/auth/documents',
31
36
  'https://www.googleapis.com/auth/drive',
@@ -33,7 +38,7 @@ const DEFAULT_SCOPES = [
33
38
  'https://www.googleapis.com/auth/chat.spaces',
34
39
  'https://www.googleapis.com/auth/chat.messages',
35
40
  'https://www.googleapis.com/auth/chat.memberships',
36
- 'https://www.googleapis.com/auth/userinfo.profile',
41
+ ...REQUIRED_IDENTITY_SCOPES,
37
42
  'https://www.googleapis.com/auth/gmail.modify',
38
43
  'https://www.googleapis.com/auth/directory.readonly',
39
44
  'https://www.googleapis.com/auth/presentations.readonly',
@@ -57,6 +62,10 @@ function ensureConfigDir() {
57
62
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
58
63
  }
59
64
 
65
+ function ensureTokensDir() {
66
+ fs.mkdirSync(TOKENS_DIR, { recursive: true });
67
+ }
68
+
60
69
  function readJson(filePath) {
61
70
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
62
71
  }
@@ -122,8 +131,29 @@ function credentialsExist() {
122
131
  return fs.existsSync(CREDENTIALS_PATH);
123
132
  }
124
133
 
125
- function tokenExists() {
126
- return fs.existsSync(TOKEN_PATH);
134
+ function normalizeEmail(email) {
135
+ const normalized = String(email || '').trim().toLowerCase();
136
+ if (!normalized) {
137
+ throw new Error('Email is required. Pass --email <account@example.com>.');
138
+ }
139
+
140
+ if (!/^[^@\s]+@[^@\s]+$/.test(normalized)) {
141
+ throw new Error(`Invalid email address: ${email}`);
142
+ }
143
+
144
+ return normalized;
145
+ }
146
+
147
+ function tokenFilenameForEmail(email) {
148
+ return `${encodeURIComponent(normalizeEmail(email))}.json`;
149
+ }
150
+
151
+ function tokenPathForEmail(email) {
152
+ return path.join(TOKENS_DIR, tokenFilenameForEmail(email));
153
+ }
154
+
155
+ function tokenExists(email) {
156
+ return fs.existsSync(tokenPathForEmail(email));
127
157
  }
128
158
 
129
159
  function loadCredentialsFile() {
@@ -136,11 +166,84 @@ function loadCredentialsFile() {
136
166
  return readJson(CREDENTIALS_PATH);
137
167
  }
138
168
 
139
- function loadToken() {
140
- if (!tokenExists()) {
169
+ function loadToken(email) {
170
+ const tokenPath = tokenPathForEmail(email);
171
+ if (!fs.existsSync(tokenPath)) {
172
+ return null;
173
+ }
174
+ return readJson(tokenPath);
175
+ }
176
+
177
+ function decodeEmailFromTokenFilename(filename) {
178
+ if (!filename.endsWith('.json')) {
179
+ return null;
180
+ }
181
+
182
+ try {
183
+ return normalizeEmail(decodeURIComponent(filename.slice(0, -5)));
184
+ } catch {
141
185
  return null;
142
186
  }
143
- return readJson(TOKEN_PATH);
187
+ }
188
+
189
+ function listAccounts() {
190
+ if (!fs.existsSync(TOKENS_DIR)) {
191
+ return [];
192
+ }
193
+
194
+ const results = [];
195
+ const entries = fs.readdirSync(TOKENS_DIR, { withFileTypes: true });
196
+
197
+ for (const entry of entries) {
198
+ if (!entry.isFile() || !entry.name.endsWith('.json')) {
199
+ continue;
200
+ }
201
+
202
+ const tokenPath = path.join(TOKENS_DIR, entry.name);
203
+ const emailFromFilename = decodeEmailFromTokenFilename(entry.name);
204
+
205
+ let token;
206
+ try {
207
+ token = readJson(tokenPath);
208
+ } catch (error) {
209
+ results.push({
210
+ email: emailFromFilename,
211
+ path: tokenPath,
212
+ error: `Failed to read token JSON: ${error.message}`,
213
+ });
214
+ continue;
215
+ }
216
+
217
+ let email = emailFromFilename;
218
+ if (token && token.__email) {
219
+ try {
220
+ email = normalizeEmail(token.__email);
221
+ } catch {
222
+ // Ignore malformed __email metadata and keep filename-derived email.
223
+ }
224
+ }
225
+
226
+ const expiryDate = token.expiry_date || null;
227
+
228
+ results.push({
229
+ email,
230
+ path: tokenPath,
231
+ authMode: token.__authMode === 'local' || token.__authMode === 'cloud'
232
+ ? token.__authMode
233
+ : null,
234
+ hasAccessToken: Boolean(token.access_token),
235
+ hasRefreshToken: Boolean(token.refresh_token),
236
+ scopes: formatScopes(token.scope),
237
+ expiryDate,
238
+ expired: expiryDate ? expiryDate < Date.now() : null,
239
+ });
240
+ }
241
+
242
+ return results.sort((a, b) => {
243
+ const left = a.email || '';
244
+ const right = b.email || '';
245
+ return left.localeCompare(right);
246
+ });
144
247
  }
145
248
 
146
249
  function createOAuthClientFromCredentials(credentialsJson) {
@@ -192,23 +295,31 @@ function resolveAuthMode(token) {
192
295
  return 'cloud';
193
296
  }
194
297
 
195
- function saveToken(token, mode) {
298
+ function saveToken(email, token, mode) {
196
299
  ensureConfigDir();
300
+ ensureTokensDir();
301
+
302
+ const normalizedEmail = normalizeEmail(email);
303
+ const tokenPath = tokenPathForEmail(normalizedEmail);
304
+
197
305
  const payload = {
198
306
  ...token,
199
307
  __authMode: mode,
308
+ __email: normalizedEmail,
200
309
  };
201
- fs.writeFileSync(TOKEN_PATH, JSON.stringify(payload, null, 2));
310
+
311
+ fs.writeFileSync(tokenPath, JSON.stringify(payload, null, 2));
202
312
  try {
203
- fs.chmodSync(TOKEN_PATH, 0o600);
313
+ fs.chmodSync(tokenPath, 0o600);
204
314
  } catch {
205
315
  // Non-POSIX file systems can fail chmod. Ignore.
206
316
  }
207
317
  }
208
318
 
209
- function clearToken() {
210
- if (tokenExists()) {
211
- fs.rmSync(TOKEN_PATH);
319
+ function clearToken(email) {
320
+ const tokenPath = tokenPathForEmail(email);
321
+ if (fs.existsSync(tokenPath)) {
322
+ fs.rmSync(tokenPath);
212
323
  }
213
324
  }
214
325
 
@@ -274,24 +385,57 @@ async function refreshViaCloudFunction(refreshToken) {
274
385
  return response.json();
275
386
  }
276
387
 
277
- async function interactiveLoginLocal(scopes) {
388
+ function mergeWithIdentityScopes(scopes) {
389
+ return Array.from(new Set([...formatScopes(scopes), ...REQUIRED_IDENTITY_SCOPES]));
390
+ }
391
+
392
+ async function fetchAuthenticatedEmail(authClient) {
393
+ const google = getGoogleApis();
394
+ const oauth2 = google.oauth2({ version: 'v2', auth: authClient });
395
+ const response = await oauth2.userinfo.get();
396
+ const email = response?.data?.email;
397
+ if (!email) {
398
+ throw new Error(
399
+ 'Authentication succeeded, but Google did not return an email address. ' +
400
+ 'Ensure userinfo.email scope is granted.',
401
+ );
402
+ }
403
+ return normalizeEmail(email);
404
+ }
405
+
406
+ async function ensureExpectedEmail(authClient, expectedEmail) {
407
+ const normalizedExpectedEmail = normalizeEmail(expectedEmail);
408
+ const authenticatedEmail = await fetchAuthenticatedEmail(authClient);
409
+
410
+ if (authenticatedEmail !== normalizedExpectedEmail) {
411
+ throw new Error(
412
+ `Authenticated as ${authenticatedEmail}, but expected ${normalizedExpectedEmail}. ` +
413
+ 'Sign in with the requested account and retry.',
414
+ );
415
+ }
416
+
417
+ return authenticatedEmail;
418
+ }
419
+
420
+ async function interactiveLoginLocal(scopes, email) {
278
421
  const { authenticate } = loadRuntimeDeps();
279
422
  console.error('ℹ️ Opening browser for Google OAuth login (local credentials)...');
280
423
 
281
424
  const authClient = await authenticate({
282
425
  keyfilePath: CREDENTIALS_PATH,
283
- scopes,
426
+ scopes: mergeWithIdentityScopes(scopes),
284
427
  });
285
428
 
286
429
  if (!authClient.credentials || !authClient.credentials.access_token) {
287
430
  throw new Error('Authentication failed: no access token returned.');
288
431
  }
289
432
 
290
- saveToken(authClient.credentials, 'local');
433
+ const normalizedEmail = await ensureExpectedEmail(authClient, email);
434
+ saveToken(normalizedEmail, authClient.credentials, 'local');
291
435
  return authClient;
292
436
  }
293
437
 
294
- async function interactiveLoginCloud(scopes) {
438
+ async function interactiveLoginCloud(scopes, email) {
295
439
  const client = createCloudOAuthClient();
296
440
  const { cloudFunctionUrl } = getWorkspaceClientConfig();
297
441
 
@@ -312,7 +456,7 @@ async function interactiveLoginCloud(scopes) {
312
456
  const authUrl = client.generateAuthUrl({
313
457
  redirect_uri: cloudFunctionUrl,
314
458
  access_type: 'offline',
315
- scope: scopes,
459
+ scope: mergeWithIdentityScopes(scopes),
316
460
  state,
317
461
  prompt: 'consent',
318
462
  });
@@ -436,15 +580,16 @@ async function interactiveLoginCloud(scopes) {
436
580
  });
437
581
 
438
582
  client.setCredentials(credentials);
439
- saveToken(credentials, 'cloud');
583
+ const normalizedEmail = await ensureExpectedEmail(client, email);
584
+ saveToken(normalizedEmail, credentials, 'cloud');
440
585
  return client;
441
586
  }
442
587
 
443
- async function interactiveLogin(scopes, mode) {
588
+ async function interactiveLogin(scopes, mode, email) {
444
589
  if (mode === 'local') {
445
- return interactiveLoginLocal(scopes);
590
+ return interactiveLoginLocal(scopes, email);
446
591
  }
447
- return interactiveLoginCloud(scopes);
592
+ return interactiveLoginCloud(scopes, email);
448
593
  }
449
594
 
450
595
  function stripInternalTokenFields(token) {
@@ -453,17 +598,20 @@ function stripInternalTokenFields(token) {
453
598
  }
454
599
  const clone = { ...token };
455
600
  delete clone.__authMode;
601
+ delete clone.__email;
456
602
  return clone;
457
603
  }
458
604
 
459
605
  async function authorize(options = {}) {
460
- const scopes = options.scopes || DEFAULT_SCOPES;
606
+ const email = normalizeEmail(options.email);
607
+ const scopes = mergeWithIdentityScopes(options.scopes || DEFAULT_SCOPES);
461
608
  const interactive = options.interactive !== false;
462
609
 
463
610
  ensureConfigDir();
611
+ ensureTokensDir();
464
612
  loadRuntimeDeps();
465
613
 
466
- const token = loadToken();
614
+ const token = loadToken(email);
467
615
  const mode = resolveAuthMode(token);
468
616
 
469
617
  const client =
@@ -473,9 +621,22 @@ async function authorize(options = {}) {
473
621
 
474
622
  if (!token) {
475
623
  if (!interactive) {
476
- throw new Error(`No token found at ${TOKEN_PATH}. Run: node scripts/auth.js login`);
624
+ throw new Error(
625
+ `No token found for ${email} at ${tokenPathForEmail(email)}. ` +
626
+ `Run: node scripts/auth.js login --email ${email}`,
627
+ );
628
+ }
629
+ return interactiveLogin(scopes, mode, email);
630
+ }
631
+
632
+ if (token.__email) {
633
+ const tokenEmail = normalizeEmail(token.__email);
634
+ if (tokenEmail !== email) {
635
+ throw new Error(
636
+ `Token metadata mismatch. Requested ${email} but token is tagged as ${tokenEmail}. ` +
637
+ `Delete it and sign in again: node scripts/auth.js clear --email ${email}`,
638
+ );
477
639
  }
478
- return interactiveLogin(scopes, mode);
479
640
  }
480
641
 
481
642
  client.setCredentials(stripInternalTokenFields(token));
@@ -493,7 +654,7 @@ async function authorize(options = {}) {
493
654
  refreshed.credentials.refresh_token || client.credentials.refresh_token,
494
655
  };
495
656
  client.setCredentials(merged);
496
- saveToken(merged, 'local');
657
+ saveToken(email, merged, 'local');
497
658
  return client;
498
659
  }
499
660
 
@@ -505,15 +666,18 @@ async function authorize(options = {}) {
505
666
  refresh_token: client.credentials.refresh_token,
506
667
  };
507
668
  client.setCredentials(merged);
508
- saveToken(merged, 'cloud');
669
+ saveToken(email, merged, 'cloud');
509
670
  return client;
510
671
  }
511
672
 
512
673
  if (!interactive) {
513
- throw new Error('Token is expired and no refresh token is available. Run login again.');
674
+ throw new Error(
675
+ `Token for ${email} is expired and no refresh token is available. ` +
676
+ `Run: node scripts/auth.js login --email ${email}`,
677
+ );
514
678
  }
515
679
 
516
- return interactiveLogin(scopes, mode);
680
+ return interactiveLogin(scopes, mode, email);
517
681
  }
518
682
 
519
683
  function formatScopes(value) {
@@ -532,15 +696,20 @@ function formatScopes(value) {
532
696
  module.exports = {
533
697
  CONFIG_DIR,
534
698
  CREDENTIALS_PATH,
535
- TOKEN_PATH,
699
+ TOKENS_DIR,
536
700
  DEFAULT_SCOPES,
537
701
  DEFAULT_VERSIONS,
702
+ REQUIRED_IDENTITY_SCOPES,
538
703
  authorize,
539
704
  clearToken,
540
705
  credentialsExist,
541
706
  formatScopes,
542
707
  getGoogleApis,
543
708
  getWorkspaceClientConfig,
709
+ listAccounts,
544
710
  loadToken,
711
+ normalizeEmail,
545
712
  resolveAuthMode,
713
+ tokenExists,
714
+ tokenPathForEmail,
546
715
  };