atris 3.80.0 → 3.82.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.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -0
  3. package/atris/atris.md +14 -0
  4. package/atris/features/README.md +15 -0
  5. package/atris/policies/README.md +24 -0
  6. package/atris/skills/README.md +61 -45
  7. package/atris/skills/design/SKILL.md +6 -5
  8. package/atris/skills/taste/SKILL.md +39 -0
  9. package/atris/skills/taste/notes.md +29 -0
  10. package/atris/skills/taste/principles.md +23 -0
  11. package/atris/skills/wiki/SKILL.md +14 -2
  12. package/atris/skills/wiki/index-lines.md +30 -0
  13. package/atris/skills/wiki/measure.md +20 -0
  14. package/atris/skills/wiki/note-shape.md +16 -0
  15. package/atris/skills/wiki/upkeep.md +16 -0
  16. package/atris/team/README.md +38 -0
  17. package/atris.md +14 -0
  18. package/bin/atris.js +1 -1
  19. package/commands/activate.js +1 -1
  20. package/commands/auth.js +2 -2
  21. package/commands/autoland.js +25 -26
  22. package/commands/autopilot-front.js +46 -1
  23. package/commands/autopilot.js +55 -25
  24. package/commands/clean.js +22 -11
  25. package/commands/computer.js +166 -2413
  26. package/commands/doc-health.js +48 -1
  27. package/commands/improve.js +16 -3
  28. package/commands/init.js +4 -0
  29. package/commands/integrations.js +69 -1616
  30. package/commands/mission.js +109 -13
  31. package/commands/release.js +41 -10
  32. package/commands/security-review.js +5 -1
  33. package/commands/site-deploy.js +5 -1
  34. package/commands/status.js +1 -1
  35. package/commands/sync.js +12 -8
  36. package/commands/task.js +144 -118
  37. package/commands/unknowns.js +9 -5
  38. package/commands/visualize.js +1 -12
  39. package/commands/workflow.js +4 -3
  40. package/commands/worktree.js +68 -1602
  41. package/commands/xp.js +23 -2113
  42. package/commands/youtube.js +192 -3110
  43. package/lib/business-core.js +3 -4
  44. package/lib/campaign-run-lock.js +12 -1
  45. package/lib/computer-core.js +2447 -0
  46. package/lib/engine-ask.js +1 -1
  47. package/lib/engine-receipt-sweep.js +64 -0
  48. package/lib/file-lock.js +175 -0
  49. package/lib/fleet.js +1 -1
  50. package/lib/integrations-core.js +1695 -0
  51. package/lib/json-file.js +22 -1
  52. package/lib/loop-doctor.js +36 -2
  53. package/lib/map-refs.js +68 -8
  54. package/lib/member-engine.js +1 -1
  55. package/lib/member-map-fold.js +49 -11
  56. package/lib/member-map-inbox.js +2 -1
  57. package/lib/mission-core.js +9 -2
  58. package/lib/recall-exam.js +171 -0
  59. package/lib/roster-markdown.js +2 -2
  60. package/lib/runner-command.js +5 -5
  61. package/lib/security-audit-classes.js +20 -2
  62. package/lib/security-audit.js +31 -1
  63. package/lib/task-db.js +105 -37
  64. package/lib/taste-notes.js +35 -0
  65. package/lib/team-lineup.js +3 -3
  66. package/lib/todo.js +2 -0
  67. package/lib/wiki.js +11 -3
  68. package/lib/worktree-core.js +1517 -0
  69. package/lib/xp-core.js +2172 -0
  70. package/lib/youtube-core.js +3178 -0
  71. package/lib/zen-apply.js +3 -1
  72. package/package.json +10 -3
  73. package/utils/auth.js +18 -4
@@ -0,0 +1,1695 @@
1
+ const { AsyncLocalStorage } = require('async_hooks');
2
+ const { createHash } = require('crypto');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const readline = require('readline');
7
+ const { spawnSync } = require('child_process');
8
+ const { resolveBusiness } = require('./business-target');
9
+ const {
10
+ assertOutboundSendAllowedOrExit,
11
+ extractOutboundGateOptions,
12
+ } = require('./outbound-send-gate');
13
+ const { stateHome } = require('./state-home');
14
+ const { sleepSync } = require('./sleep-sync');
15
+
16
+ const googleContext = new AsyncLocalStorage();
17
+
18
+ function loadCredentials() {
19
+ return require('../utils/auth').loadCredentials();
20
+ }
21
+
22
+ function ensureValidCredentials(...args) {
23
+ return require('../utils/auth').ensureValidCredentials(...args);
24
+ }
25
+
26
+ function rawApiRequestJson(...args) {
27
+ return require('../utils/api').apiRequestJson(...args);
28
+ }
29
+
30
+ function apiRequestJson(url, options = {}) {
31
+ const context = googleContext.getStore();
32
+ if (!context || !/^\/integrations\/(gmail|google-calendar|google)(?:\/|$)/.test(url)) return rawApiRequestJson(url, options);
33
+ const next = { ...options };
34
+ if (context.business) next.headers = { ...(options.headers || {}), 'X-Business-Id': context.business };
35
+ if (context.calendarAccount && url.startsWith('/integrations/google-calendar/')) {
36
+ if ((options.method || 'GET') === 'GET' || options.method === 'DELETE') {
37
+ url += `${url.includes('?') ? '&' : '?'}account_id=${encodeURIComponent(context.calendarAccount)}`;
38
+ } else next.body = { ...(options.body || {}), account_id: context.calendarAccount };
39
+ }
40
+ if (context.serverDefaults && !context.mailAccount && url.startsWith('/integrations/gmail/') && !/\/accounts|\/start/.test(url)) {
41
+ const parts = url.split('?');
42
+ if (parts[1]) { const q = new URLSearchParams(parts[1]); q.delete('account_id'); url = parts[0] + (q.toString() ? `?${q}` : ''); }
43
+ if (next.body && Object.hasOwn(next.body, 'account_id')) { next.body = { ...next.body }; delete next.body.account_id; }
44
+ }
45
+ return rawApiRequestJson(url, next);
46
+ }
47
+
48
+ function takeContext(args) {
49
+ const rest = []; let businessFlag; let calendarAccount; let mailAccount;
50
+ for (let i = 0; i < args.length; i += 1) {
51
+ const arg = String(args[i]);
52
+ if (arg === '--business' || arg.startsWith('--business=')) {
53
+ businessFlag = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++i];
54
+ if (!businessFlag || String(businessFlag).startsWith('--')) throw new Error('--business needs a business slug or id');
55
+ } else if (arg === '--account' || arg.startsWith('--account=')) {
56
+ const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++i];
57
+ if (!value || String(value).startsWith('--')) throw new Error('--account needs an account name or email');
58
+ calendarAccount = mailAccount = String(value);
59
+ rest.push('--account', String(value));
60
+ } else rest.push(args[i]);
61
+ }
62
+ const business = resolveBusiness(businessFlag === undefined ? {} : { business: businessFlag });
63
+ if (business && business.error) throw new Error(business.error);
64
+ return { rest, business: business && business.id, explicitBusiness: businessFlag !== undefined, calendarAccount, mailAccount };
65
+ }
66
+
67
+ function calendarCachePath() {
68
+ return path.join(stateHome(), '.atris', 'calendar-events-cache.json');
69
+ }
70
+
71
+ const GMAIL_CONNECT_POLL_MS = 3000;
72
+ const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
73
+ const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>]';
74
+ const GMAIL_VOICE_USAGE = 'usage: atris gmail voice [account] [--clear]';
75
+ const GMAIL_TRIAGE_USAGE = 'usage: atris gmail triage [--account <id>] [--limit N]';
76
+ const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--summary] [--account <id>] [--limit N]';
77
+ const GMAIL_BULK_DOMAINS = [
78
+ 'campaign-archive.com',
79
+ 'constantcontact.com',
80
+ 'hubspotemail.net',
81
+ 'mailchimp.com',
82
+ 'mailchimpapp.net',
83
+ 'mailgun.org',
84
+ 'sendgrid.net',
85
+ 'substack.com',
86
+ ];
87
+
88
+ function gmailVerdictsPath(root = process.cwd()) {
89
+ return path.join(root, '.atris', 'state', 'gmail-verdicts.jsonl');
90
+ }
91
+
92
+ function appendGmailVerdicts(verdicts, options = {}) {
93
+ const rows = Array.isArray(verdicts) ? verdicts : [];
94
+ if (!rows.length) return;
95
+ const filePath = options.filePath || gmailVerdictsPath(options.root);
96
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
97
+ const lines = rows.map((row) => JSON.stringify({
98
+ ts: row.ts || new Date().toISOString(),
99
+ account: String(row.account || ''),
100
+ verdict: row.verdict === 'keep' ? 'keep' : 'archive',
101
+ message_id: String(row.message_id || ''),
102
+ ...(row.from ? { from: row.from } : {}),
103
+ ...(row.subject ? { subject: row.subject } : {}),
104
+ ...(typeof row.reason === 'string' && row.reason ? { reason: row.reason } : {}),
105
+ }));
106
+ fs.appendFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
107
+ }
108
+
109
+ function readGmailVerdicts(options = {}) {
110
+ const filePath = options.filePath || gmailVerdictsPath(options.root);
111
+ let lines;
112
+ try {
113
+ lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
114
+ } catch (error) {
115
+ if (error.code === 'ENOENT') return [];
116
+ throw error;
117
+ }
118
+ const account = String(options.account || '').trim();
119
+ const limit = options.limit === null ? null : (Number.isInteger(options.limit) ? options.limit : 20);
120
+ const rows = lines.reduce((matches, line) => {
121
+ try {
122
+ const row = JSON.parse(line);
123
+ if (!account || row.account === account) matches.push(row);
124
+ } catch {}
125
+ return matches;
126
+ }, []).reverse();
127
+ return limit === null ? rows : rows.slice(0, limit);
128
+ }
129
+
130
+ function summarizeGmailVerdicts(rows = []) {
131
+ const groups = new Map();
132
+ for (const row of rows) {
133
+ const date = String(row.ts || '').slice(0, 10);
134
+ const account = String(row.account || '');
135
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
136
+ const key = `${date}\n${account}`;
137
+ const summary = groups.get(key) || { date, account, keep: 0, archive: 0 };
138
+ summary[row.verdict === 'keep' ? 'keep' : 'archive'] += 1;
139
+ groups.set(key, summary);
140
+ }
141
+ return [...groups.values()].sort((left, right) => (
142
+ right.date.localeCompare(left.date) || left.account.localeCompare(right.account)
143
+ ));
144
+ }
145
+
146
+ function printGmailVerdicts(options = {}) {
147
+ const rows = readGmailVerdicts(options.summary ? { ...options, limit: null } : options);
148
+ if (options.summary) {
149
+ const summaries = summarizeGmailVerdicts(rows);
150
+ if (!summaries.length) {
151
+ console.log('no gmail verdicts found.');
152
+ return summaries;
153
+ }
154
+ for (const row of summaries) {
155
+ console.log(`${row.date} account ${row.account}, keep ${row.keep}, archive ${row.archive}`);
156
+ }
157
+ return summaries;
158
+ }
159
+ if (!rows.length) {
160
+ console.log('no gmail verdicts found.');
161
+ return rows;
162
+ }
163
+ for (const row of rows) {
164
+ const details = [
165
+ row.reason && `reason ${row.reason}`,
166
+ row.from && `from ${row.from}`,
167
+ row.subject && `subject ${row.subject}`,
168
+ ].filter(Boolean);
169
+ console.log(`${row.ts} ${row.verdict || 'archive'} ${row.message_id} account ${row.account}${details.length ? `, ${details.join(', ')}` : ''}`);
170
+ }
171
+ return rows;
172
+ }
173
+
174
+ function parseGmailTriageArgs(args = []) {
175
+ let accountId = null;
176
+ let limit = 25;
177
+ for (let i = 0; i < args.length; i += 1) {
178
+ const arg = args[i];
179
+ const value = String(args[i + 1] || '').trim();
180
+ if (arg === '--account' && value && !value.startsWith('--')) accountId = value;
181
+ else if (arg === '--limit' && /^\d+$/.test(value) && Number(value) > 0) limit = Number(value);
182
+ else {
183
+ console.error(GMAIL_TRIAGE_USAGE);
184
+ process.exit(1);
185
+ }
186
+ i += 1;
187
+ }
188
+ return { accountId, limit };
189
+ }
190
+
191
+ function parseGmailVerdictsArgs(args = []) {
192
+ let account = null;
193
+ let limit = 20;
194
+ let summary = false;
195
+ for (let i = 0; i < args.length; i += 1) {
196
+ const arg = args[i];
197
+ const value = String(args[i + 1] || '').trim();
198
+ if (arg === '--summary') {
199
+ summary = true;
200
+ continue;
201
+ }
202
+ if (arg === '--account' && value && !value.startsWith('--')) account = value;
203
+ else if (arg === '--limit' && /^\d+$/.test(value) && Number(value) > 0) limit = Number(value);
204
+ else {
205
+ console.error(GMAIL_VERDICTS_USAGE);
206
+ process.exit(1);
207
+ }
208
+ i += 1;
209
+ }
210
+ return { account, limit, summary };
211
+ }
212
+
213
+ function gmailAccountStatePath() {
214
+ return process.env.ATRIS_GMAIL_ACCOUNT_FILE
215
+ || path.join(stateHome(), '.atris', 'gmail-account.json');
216
+ }
217
+
218
+ function readGmailStickyAccount() {
219
+ try {
220
+ const data = JSON.parse(fs.readFileSync(gmailAccountStatePath(), 'utf8'));
221
+ const id = String(data.account_id || '').trim();
222
+ return id || null;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ function writeGmailStickyAccount(accountId) {
229
+ const filePath = gmailAccountStatePath();
230
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
231
+ fs.writeFileSync(filePath, `${JSON.stringify({ account_id: accountId }, null, 2)}\n`, 'utf8');
232
+ }
233
+
234
+ function mailMigrationRecord(auth) {
235
+ const markerPath = `${gmailAccountStatePath()}.migrated`;
236
+ let subject = '';
237
+ try { subject = JSON.parse(Buffer.from(auth.token.split('.')[1], 'base64url').toString()).sub || ''; } catch {}
238
+ const owner = auth.userId || (auth.email !== 'unknown' ? auth.email.toLowerCase() : subject || auth.token);
239
+ const identity = createHash('sha256').update(owner).digest('hex');
240
+ let migrated = {};
241
+ try { migrated = JSON.parse(fs.readFileSync(markerPath, 'utf8')); } catch {}
242
+ if (!migrated || typeof migrated !== 'object' || Array.isArray(migrated)) migrated = {};
243
+ return { markerPath, identity, migrated };
244
+ }
245
+
246
+ function markMailMigration(record) {
247
+ record.migrated[record.identity] = true;
248
+ fs.mkdirSync(path.dirname(record.markerPath), { recursive: true });
249
+ fs.writeFileSync(record.markerPath, `${JSON.stringify(record.migrated)}\n`, { mode: 0o600 });
250
+ }
251
+
252
+ async function googleAccountsCapability(token, { refresh = false } = {}) {
253
+ const context = googleContext.getStore();
254
+ if (!refresh && context?.googleCapability) return context.googleCapability;
255
+ const result = await apiRequestJson('/integrations/google/accounts', { method: 'GET', token });
256
+ if (!result.ok && result.status !== 404) {
257
+ const error = new Error(`could not check google accounts: ${result.error || result.data?.detail || 'request failed'}`);
258
+ error.status = result.status || 0;
259
+ error.result = result;
260
+ throw error;
261
+ }
262
+ if (result.ok && !Array.isArray(result.data?.accounts)) {
263
+ const error = new Error('could not check google accounts: the server returned an incomplete account list');
264
+ error.status = result.status || 0;
265
+ error.result = result;
266
+ throw error;
267
+ }
268
+ const capability = { supported: result.ok, data: result.data || {}, status: result.status || 0 };
269
+ if (context) context.googleCapability = capability;
270
+ return capability;
271
+ }
272
+
273
+ async function prepareMailDefaults(context, { migrate = true } = {}) {
274
+ const auth = await getAuth();
275
+ const capability = await googleAccountsCapability(auth.token);
276
+ if (!capability.supported) {
277
+ const sticky = readGmailStickyAccount();
278
+ if (sticky && sticky.includes('@')) {
279
+ const account = resolveAccountSelector(await fetchGmailAccounts(auth.token), sticky);
280
+ if (!account) throw new Error('saved gmail account is not connected');
281
+ context.resolvedMail = gmailAccountIdentity(account).id;
282
+ }
283
+ return;
284
+ }
285
+ let data = capability.data;
286
+ if (!data || !Array.isArray(data.accounts) || !data.defaults || !Object.hasOwn(data.defaults, 'gmail') || !data.resolved || !Object.hasOwn(data.resolved, 'gmail')) {
287
+ throw new Error('could not resolve mail account: the server returned an incomplete account list');
288
+ }
289
+ const record = mailMigrationRecord(auth);
290
+ if (migrate && !record.migrated[record.identity]) {
291
+ const sticky = readGmailStickyAccount();
292
+ const legacy = sticky && resolveAccountSelector(data.accounts, sticky);
293
+ const eligible = legacy && legacy.products?.gmail?.connected;
294
+ if (sticky && data.defaults.gmail == null && !eligible) console.error('saved mail account is no longer connected; using the server choice.');
295
+ if (sticky && data.defaults.gmail == null && eligible) {
296
+ const canonical = gmailAccountIdentity(legacy).id;
297
+ const saved = await rawApiRequestJson('/integrations/google/defaults', {
298
+ method: 'PUT', token: auth.token, body: { account_id: canonical, products: ['gmail'] },
299
+ });
300
+ if (!saved.ok) return googleFailure(saved, 'move the saved mail default');
301
+ const refreshed = await googleAccountsCapability(auth.token, { refresh: true });
302
+ if (!refreshed.supported) throw new Error('could not refresh mail account');
303
+ data = refreshed.data;
304
+ if (!data || !data.resolved || !Object.hasOwn(data.resolved, 'gmail')) throw new Error('could not refresh mail account');
305
+ }
306
+ markMailMigration(record);
307
+ }
308
+ context.serverDefaults = true;
309
+ context.resolvedMail = data.resolved.gmail || null;
310
+ }
311
+
312
+ function resolveGmailAccountId(explicitId) {
313
+ const trimmed = String(explicitId || '').trim();
314
+ if (trimmed) return trimmed;
315
+ const context = googleContext.getStore();
316
+ if (context?.resolvedMail) return context.resolvedMail;
317
+ const sticky = readGmailStickyAccount();
318
+ if (sticky) return sticky;
319
+ return 'default';
320
+ }
321
+
322
+ function gmailAccountIdentity(account = {}) {
323
+ const id = String(account.account_id || account.id || 'default').trim() || 'default';
324
+ const email = String(account.email_address || account.email || '').trim().toLowerCase();
325
+ const name = String(account.display_name || account.name || id).trim().toLowerCase() || id.toLowerCase();
326
+ return { id, email, name };
327
+ }
328
+
329
+ function sortGmailAccounts(accounts) {
330
+ return [...accounts].sort((left, right) => {
331
+ const leftIdentity = gmailAccountIdentity(left);
332
+ const rightIdentity = gmailAccountIdentity(right);
333
+ const leftDefault = leftIdentity.id.toLowerCase() === 'default';
334
+ const rightDefault = rightIdentity.id.toLowerCase() === 'default';
335
+ if (leftDefault !== rightDefault) return leftDefault ? -1 : 1;
336
+ return leftIdentity.name.localeCompare(rightIdentity.name)
337
+ || leftIdentity.id.localeCompare(rightIdentity.id);
338
+ });
339
+ }
340
+
341
+ function formatGmailAccountRows(accounts, activeId = null) {
342
+ const rows = sortGmailAccounts(accounts).map((account) => {
343
+ const identity = gmailAccountIdentity(account);
344
+ return { ...identity, emailLabel: identity.email || 'email pending' };
345
+ });
346
+ const nameWidth = Math.max(...rows.map((row) => row.name.length));
347
+ const emailWidth = Math.max(...rows.map((row) => row.emailLabel.length));
348
+ const active = String(activeId || '').toLowerCase();
349
+ return rows.map((row) => {
350
+ const marker = row.id.toLowerCase() === active ? ' (active)' : '';
351
+ return `${row.name.padEnd(nameWidth)} ${row.emailLabel.padEnd(emailWidth)}${marker}`.trimEnd();
352
+ });
353
+ }
354
+
355
+ async function fetchGmailAccounts(token, options = {}) {
356
+ const result = await apiRequestJson('/integrations/gmail/accounts', {
357
+ method: 'GET',
358
+ token,
359
+ });
360
+ if (!result.ok) {
361
+ if (options.quiet) return [];
362
+ console.error(`Error: ${result.error || 'Failed to fetch accounts'}`);
363
+ process.exit(1);
364
+ }
365
+ const accounts = result.data?.accounts || result.data || [];
366
+ return Array.isArray(accounts) ? accounts : [];
367
+ }
368
+
369
+ function resolveAccountSelector(accounts, selector, { displayName = false } = {}) {
370
+ const wanted = String(selector || '').trim().toLowerCase();
371
+ if (!wanted) return null;
372
+ const exact = accounts.filter((account) => {
373
+ const identity = gmailAccountIdentity(account);
374
+ return identity.id.toLowerCase() === wanted || identity.email === wanted;
375
+ });
376
+ const matches = exact.length || !displayName ? exact : accounts.filter((account) => gmailAccountIdentity(account).name === wanted);
377
+ if (matches.length > 1) {
378
+ const labels = matches.map((account) => {
379
+ const { id, email } = gmailAccountIdentity(account);
380
+ return `${id} (${email || 'email pending'})`;
381
+ });
382
+ throw new Error(`ambiguous account "${selector}": ${labels.join(', ')}. use an account name or email.`);
383
+ }
384
+ return matches[0] || null;
385
+ }
386
+
387
+ async function promptForGmailAccount(deps = {}) {
388
+ if (deps.prompt) return deps.prompt('choose account: ');
389
+ const rl = readline.createInterface({
390
+ input: deps.input || process.stdin,
391
+ output: deps.output || process.stdout,
392
+ });
393
+ return new Promise((resolve) => {
394
+ rl.question('choose account: ', (answer) => {
395
+ rl.close();
396
+ resolve(answer);
397
+ });
398
+ });
399
+ }
400
+
401
+ async function getAuth() {
402
+ const ensured = await ensureValidCredentials(apiRequestJson);
403
+ const creds = ensured.error ? null : ensured.credentials;
404
+ if (!creds || !creds.token) {
405
+ if (ensured.error && ensured.error !== 'not_logged_in') {
406
+ console.error(`Authentication failed: ${ensured.detail || ensured.error}. Run: atris login`);
407
+ } else {
408
+ console.error('Not logged in. Run: atris login');
409
+ }
410
+ process.exit(1);
411
+ }
412
+ return { token: creds.token, email: creds.email || 'unknown', userId: creds.user_id || null };
413
+ }
414
+
415
+ async function getAuthToken() {
416
+ return (await getAuth()).token;
417
+ }
418
+
419
+ function extractGmailMailboxAccount(args = []) {
420
+ const out = [];
421
+ let mailboxAccountId = null;
422
+ for (let i = 0; i < args.length; i += 1) {
423
+ const arg = args[i];
424
+ if (arg === '--account' && args[i + 1] && !String(args[i + 1]).startsWith('-')) {
425
+ mailboxAccountId = args[i + 1];
426
+ i += 1;
427
+ continue;
428
+ }
429
+ out.push(arg);
430
+ }
431
+ return { args: out, mailboxAccountId };
432
+ }
433
+
434
+ function parseGmailArgs(args = []) {
435
+ const options = { accountId: null };
436
+ const positional = [];
437
+ for (let i = 0; i < args.length; i += 1) {
438
+ const arg = args[i];
439
+ if (arg === '--account') {
440
+ options.accountId = String(args[i + 1] || '').trim();
441
+ if (!options.accountId) {
442
+ console.error('Usage: --account requires an account id');
443
+ process.exit(1);
444
+ }
445
+ i += 1;
446
+ } else {
447
+ positional.push(arg);
448
+ }
449
+ }
450
+ return { ...options, positional };
451
+ }
452
+
453
+ function gmailMessageHeaderText(message = {}) {
454
+ const lines = [];
455
+ const headers = message.headers;
456
+ if (Array.isArray(headers)) {
457
+ for (const header of headers) {
458
+ lines.push(`${String(header?.name || '')}: ${String(header?.value || '')}`);
459
+ }
460
+ } else if (headers && typeof headers === 'object') {
461
+ for (const [name, value] of Object.entries(headers)) lines.push(`${name}: ${String(value || '')}`);
462
+ } else if (headers) {
463
+ lines.push(String(headers));
464
+ }
465
+
466
+ for (const name of ['list_id', 'list_unsubscribe', 'mailing_list', 'unsubscribe']) {
467
+ if (message[name]) lines.push(`${name}: ${String(message[name])}`);
468
+ }
469
+ return lines.join('\n').toLowerCase();
470
+ }
471
+
472
+ function gmailTriageVerdict(message = {}, options = {}) {
473
+ const from = String(message.from || message.sender || '').toLowerCase();
474
+ let result;
475
+ if (/(?:^|[^a-z0-9])(?:no[-_.]?reply|do[-_.]?not[-_.]?reply)(?:[^a-z0-9]|$)/i.test(from)) {
476
+ result = { verdict: 'archive', reason: 'noreply sender' };
477
+ }
478
+
479
+ const domain = from.match(/@([a-z0-9.-]+)/i)?.[1] || '';
480
+ if (!result && GMAIL_BULK_DOMAINS.some((bulkDomain) => domain === bulkDomain || domain.endsWith(`.${bulkDomain}`))) {
481
+ result = { verdict: 'archive', reason: 'bulk mail domain' };
482
+ }
483
+
484
+ const headers = gmailMessageHeaderText(message);
485
+ if (!result && (/(?:^|\n)(?:list-id|list-unsubscribe|mailing-list|x-campaign-id|x-mailing-list)\s*:/i.test(headers)
486
+ || /(?:^|\n)precedence\s*:\s*(?:bulk|junk|list)/i.test(headers)
487
+ || /\bunsubscribe\b/i.test(headers))) {
488
+ result = { verdict: 'archive', reason: 'list or unsubscribe headers' };
489
+ }
490
+
491
+ if (!result) result = { verdict: 'keep', reason: 'personal sender' };
492
+ return options.details ? result : result.verdict;
493
+ }
494
+
495
+ function parseGmailSendArgs(args = []) {
496
+ let accountId = null;
497
+ let bodyFile = null;
498
+ let approved = false;
499
+ const positional = [];
500
+
501
+ for (let i = 0; i < args.length; i += 1) {
502
+ const arg = args[i];
503
+ if (arg === '--approved' || arg === '--confirm-approved') {
504
+ approved = true;
505
+ continue;
506
+ }
507
+ if (arg === '--account' || arg === '--body-file') {
508
+ const value = String(args[i + 1] || '').trim();
509
+ if (!value || value.startsWith('--')) {
510
+ console.error(GMAIL_SEND_USAGE);
511
+ process.exit(2);
512
+ }
513
+ if (arg === '--account') accountId = value;
514
+ else bodyFile = value;
515
+ i += 1;
516
+ continue;
517
+ }
518
+ positional.push(arg);
519
+ }
520
+
521
+ const [to, subject, ...bodyParts] = positional;
522
+ if (!String(to || '').trim()
523
+ || !String(subject || '').trim()
524
+ || (bodyFile ? bodyParts.length > 0 : bodyParts.length === 0)) {
525
+ console.error(GMAIL_SEND_USAGE);
526
+ process.exit(2);
527
+ }
528
+
529
+ let body = bodyParts.join(' ');
530
+ if (bodyFile) {
531
+ try {
532
+ body = fs.readFileSync(bodyFile, 'utf8');
533
+ } catch (error) {
534
+ console.error(`could not read body file "${bodyFile}": ${error.message}`);
535
+ process.exit(1);
536
+ }
537
+ }
538
+
539
+ return { to, subject, body, accountId, approved };
540
+ }
541
+
542
+ function parseGmailVoiceArgs(args = []) {
543
+ let clear = false;
544
+ const positional = [];
545
+ for (const arg of args) {
546
+ if (arg === '--clear') clear = true;
547
+ else positional.push(arg);
548
+ }
549
+ if (positional.length > 1 || positional.some((arg) => String(arg).startsWith('--'))) {
550
+ console.error(GMAIL_VOICE_USAGE);
551
+ process.exit(2);
552
+ }
553
+ return { accountId: positional[0] || null, clear };
554
+ }
555
+
556
+ function editGmailVoice(currentVoice, deps = {}) {
557
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-gmail-voice-'));
558
+ const voicePath = path.join(tempDir, 'voice.md');
559
+ let editedVoice;
560
+ let editorError = null;
561
+
562
+ try {
563
+ fs.writeFileSync(voicePath, currentVoice, 'utf8');
564
+ const editor = deps.editor || process.env.EDITOR || 'vi';
565
+ const shell = deps.shell || process.env.SHELL || '/bin/sh';
566
+ const run = deps.spawnSync || spawnSync;
567
+ const result = run(shell, ['-c', 'exec $EDITOR "$1"', 'atris-gmail-voice', voicePath], {
568
+ stdio: 'inherit',
569
+ env: { ...process.env, EDITOR: editor },
570
+ });
571
+ if (result.error || result.status !== 0) {
572
+ editorError = result.error?.message || `editor exited with status ${result.status}`;
573
+ } else {
574
+ editedVoice = fs.readFileSync(voicePath, 'utf8');
575
+ }
576
+ } finally {
577
+ fs.rmSync(tempDir, { recursive: true, force: true });
578
+ }
579
+
580
+ if (editorError) {
581
+ console.error(`could not edit gmail voice: ${editorError}`);
582
+ process.exit(1);
583
+ }
584
+ return editedVoice;
585
+ }
586
+
587
+ function openGmailAuthUrl(authUrl, deps = {}) {
588
+ const run = deps.spawnSync || spawnSync;
589
+ const platform = deps.platform || process.platform;
590
+ const command = platform === 'darwin' ? 'open' : 'xdg-open';
591
+ const opened = run(command, [authUrl], { stdio: 'ignore' });
592
+ return !opened.error && opened.status === 0;
593
+ }
594
+
595
+ const googleOldServer = 'calendar and drive accounts need the newest atris server.';
596
+ const googleProductNames = ['gmail', 'calendar', 'drive'];
597
+
598
+ function googleProducts(value) {
599
+ if (value == null) return null;
600
+ const products = String(value).split(',').map((part) => part.trim().toLowerCase() === 'mail' ? 'gmail' : part.trim().toLowerCase());
601
+ if (!products.length || products.some((part) => !googleProductNames.includes(part)) || new Set(products).size !== products.length) throw new Error('--only needs mail, calendar, or drive');
602
+ return products;
603
+ }
604
+
605
+ function googleCache(defaults, business) {
606
+ const file = path.join(stateHome(), '.atris', 'google-accounts.json');
607
+ let cache = {};
608
+ try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
609
+ if (business) cache.businesses = { ...(cache.businesses || {}), [business]: defaults };
610
+ else cache.defaults = { ...(cache.defaults || {}), ...defaults };
611
+ fs.mkdirSync(path.dirname(file), { recursive: true });
612
+ fs.writeFileSync(file, `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
613
+ }
614
+
615
+ function googleFailure(result, action) {
616
+ console.error(`could not ${action}: ${result.error || result.data?.detail || 'request failed'}`);
617
+ process.exit(1);
618
+ }
619
+
620
+ async function saveGoogleDefault(auth, selector, products, business, { mailAccounts, warnLegacy = false } = {}) {
621
+ const capability = await googleAccountsCapability(auth.token);
622
+ if (!capability.supported && !products.includes('gmail')) {
623
+ if (warnLegacy) console.log(googleOldServer);
624
+ return { id: String(selector), legacy: true };
625
+ }
626
+ const roster = capability.supported ? (Array.isArray(capability.data.accounts) ? capability.data.accounts : []) : (mailAccounts || await fetchGmailAccounts(auth.token));
627
+ const match = resolveAccountSelector(roster, selector, { displayName: !capability.supported });
628
+ if (!match && (String(selector).includes('@') || !capability.supported)) throw new Error('unknown google account');
629
+ const id = match ? gmailAccountIdentity(match).id : String(selector);
630
+ if (!capability.supported) {
631
+ if (warnLegacy) console.log(googleOldServer);
632
+ if (products.includes('gmail')) writeGmailStickyAccount(id);
633
+ return { id, legacy: true };
634
+ }
635
+ const url = business ? `/business/${encodeURIComponent(business)}/integrations/google` : '/integrations/google/defaults';
636
+ const body = business ? { account_id: id } : { account_id: id, products };
637
+ const options = { method: 'PUT', token: auth.token, body };
638
+ if (business) options.headers = { 'X-Business-Id': business };
639
+ const saved = await rawApiRequestJson(url, options);
640
+ if (!saved.ok) return googleFailure(saved, 'save google default');
641
+ googleCache(Object.fromEntries((business ? googleProductNames : products).map((product) => [product, id])), business);
642
+ if (products.includes('gmail') && !business) {
643
+ markMailMigration(mailMigrationRecord(auth));
644
+ writeGmailStickyAccount(id);
645
+ }
646
+ return { id, legacy: false };
647
+ }
648
+
649
+ function googleSignInRetry(name, products, business) {
650
+ return ['atris google connect', name, products && `--only ${products.map((p) => p === 'gmail' ? 'mail' : p).join(',')}`, business ? `--business ${business}` : '--account'].filter(Boolean).join(' ');
651
+ }
652
+
653
+ function googleSignInFailure(result, { error, retry, status = 'error', extra = {} } = {}) {
654
+ const data = result?.data && typeof result.data === 'object' && !Array.isArray(result.data) ? result.data : {};
655
+ console.log(JSON.stringify({ ok: false, status, http_status: result?.status || 0, error: error || result?.error || 'google sign-in did not finish', retry, ...extra, ...data }, null, 2));
656
+ return 1;
657
+ }
658
+
659
+ function googleSignInErrorLines(status, business) {
660
+ const reason = String(status.error || 'unknown error').replace(/_/g, ' ').toLowerCase();
661
+ const lines = [status.error === 'superseded'
662
+ ? 'the google connection changed before sign-in finished.'
663
+ : `google sign-in did not finish: ${reason}.`];
664
+ if (status.account_id) lines.push(`account: ${status.account_id}.`);
665
+ if (Array.isArray(status.products)) {
666
+ const products = status.products.map((p) => p === 'gmail' ? 'mail' : p);
667
+ lines.push(products.length ? `products still connected: ${products.join(', ')}.` : 'no products remain connected.');
668
+ } else lines.push('which products remain connected is unknown.');
669
+ if (Array.isArray(status.failed) && status.failed.length) {
670
+ const failed = status.failed.map((p) => p === 'gmail' ? 'mail' : p === 'result' ? 'saving the sign-in result' : p);
671
+ lines.push(`could not finish: ${failed.join(', ')}.`);
672
+ }
673
+ if (Array.isArray(status.missing) && status.missing.length) lines.push(`google did not grant: ${status.missing.map((p) => p === 'gmail' ? 'mail' : p).join(', ')}.`);
674
+ if (status.cleanup_incomplete) lines.push('some changes could not be undone.');
675
+ if (status.business_assignment_unknown) lines.push('the business account choice may not have been saved.');
676
+ const check = business ? `atris google accounts --business ${business}` : 'atris google accounts --account';
677
+ lines.push(`check ${check} before trying again.`);
678
+ return lines;
679
+ }
680
+
681
+ function localDayBounds(offsetDays = 0) {
682
+ const start = new Date();
683
+ start.setHours(0, 0, 0, 0);
684
+ start.setDate(start.getDate() + offsetDays);
685
+ const end = new Date(start);
686
+ end.setDate(end.getDate() + 1);
687
+ return { start, end };
688
+ }
689
+
690
+ function localDateBounds(dateText) {
691
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dateText || ''))) {
692
+ console.error('Usage: atris calendar date YYYY-MM-DD');
693
+ process.exit(1);
694
+ }
695
+ const start = new Date(`${dateText}T00:00:00`);
696
+ if (Number.isNaN(start.getTime())) {
697
+ console.error('Invalid date. Use YYYY-MM-DD.');
698
+ process.exit(1);
699
+ }
700
+ const end = new Date(start);
701
+ end.setDate(end.getDate() + 1);
702
+ return { start, end };
703
+ }
704
+
705
+ function calendarEventTimeValue(value) {
706
+ if (!value) return '';
707
+ if (typeof value === 'string') return value;
708
+ return value.dateTime || value.date || '';
709
+ }
710
+
711
+ function isDateOnly(value) {
712
+ return /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
713
+ }
714
+
715
+ // All-day dates start at local midnight; invalid or missing dates sort last.
716
+ function compareCalendarEvents(left, right) {
717
+ const instant = (event) => {
718
+ const value = calendarEventTimeValue(event.start || event.start_time || event.startTime);
719
+ if (!value) return NaN;
720
+ if (isDateOnly(value)) {
721
+ const parsed = new Date(`${value}T00:00:00`);
722
+ if (Number.isNaN(parsed.getTime()) || `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}` !== value) return NaN;
723
+ return parsed.getTime();
724
+ }
725
+ return Date.parse(value);
726
+ };
727
+ const a = instant(left), b = instant(right);
728
+ if (!Number.isFinite(a)) return Number.isFinite(b) ? 1 : 0;
729
+ if (!Number.isFinite(b)) return -1;
730
+ return a - b;
731
+ }
732
+
733
+ function calendarReadMetadata(data) {
734
+ const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
735
+ const errors = Array.isArray(data?.errors) ? data.errors : [];
736
+ return { accounts, errors, partial: errors.length > 0 };
737
+ }
738
+
739
+ function formatCalendarTimeRange(event) {
740
+ const start = calendarEventTimeValue(event.start || event.start_time || event.startTime);
741
+ const end = calendarEventTimeValue(event.end || event.end_time || event.endTime);
742
+ if (!start || isDateOnly(start)) return 'All day';
743
+ const startDate = new Date(start);
744
+ if (Number.isNaN(startDate.getTime())) return 'Time unavailable';
745
+ const startText = startDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
746
+ if (!end || isDateOnly(end)) return startText;
747
+ const endDate = new Date(end);
748
+ if (Number.isNaN(endDate.getTime())) return startText;
749
+ return `${startText}-${endDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}`;
750
+ }
751
+
752
+ function formatCalendarEvents(label, events) {
753
+ if (events.length === 0) {
754
+ // label is already a phrase like "today's events" or "events on 2026-08-24"
755
+ console.log(`No ${label}.`);
756
+ return;
757
+ }
758
+
759
+ console.log('─'.repeat(50));
760
+
761
+ const multipleAccounts = new Set(events.map((event) => event.account_id).filter(Boolean)).size > 1;
762
+ for (const event of events) {
763
+ const time = formatCalendarTimeRange(event);
764
+ const title = event.summary || '(no title)';
765
+
766
+ const account = multipleAccounts ? ` (${event.account_email || event.account_id || 'unknown'})` : '';
767
+ console.log(`${time} ${title}${account}`);
768
+ if (event.location) {
769
+ console.log(` 📍 ${event.location}`);
770
+ }
771
+ }
772
+
773
+ console.log('─'.repeat(50));
774
+ }
775
+
776
+ function parseCalendarSearchArgs(args = []) {
777
+ const values = [];
778
+ const options = { days: 30, limit: 20, timeoutMs: 10000, json: false };
779
+ for (let i = 0; i < args.length; i += 1) {
780
+ const arg = args[i];
781
+ if (arg === '--json') {
782
+ options.json = true;
783
+ } else if (arg === '--days' || arg === '-d') {
784
+ const parsed = Number.parseInt(args[i + 1], 10);
785
+ if (Number.isFinite(parsed) && parsed > 0) options.days = Math.min(parsed, 365);
786
+ i += 1;
787
+ } else if (arg === '--limit' || arg === '-n') {
788
+ const parsed = Number.parseInt(args[i + 1], 10);
789
+ if (Number.isFinite(parsed) && parsed > 0) options.limit = Math.min(parsed, 100);
790
+ i += 1;
791
+ } else if (arg === '--timeout-ms' || arg === '--timeout') {
792
+ const parsed = Number.parseInt(args[i + 1], 10);
793
+ if (Number.isFinite(parsed) && parsed > 0) options.timeoutMs = Math.min(parsed, 60000);
794
+ i += 1;
795
+ } else {
796
+ values.push(arg);
797
+ }
798
+ }
799
+ return { query: values.join(' ').trim(), ...options };
800
+ }
801
+
802
+ function calendarAttendeesText(attendees) {
803
+ if (!Array.isArray(attendees)) return '';
804
+ return attendees
805
+ .map((attendee) => {
806
+ if (typeof attendee === 'string') return attendee;
807
+ return [
808
+ attendee.email,
809
+ attendee.displayName,
810
+ attendee.name,
811
+ attendee.responseStatus,
812
+ ].filter(Boolean).join(' ');
813
+ })
814
+ .filter(Boolean)
815
+ .join(' ');
816
+ }
817
+
818
+ function calendarSearchText(event = {}) {
819
+ return [
820
+ event.summary,
821
+ event.title,
822
+ event.location,
823
+ event.description,
824
+ calendarAttendeesText(event.attendees),
825
+ ].filter(Boolean).join(' ').toLowerCase();
826
+ }
827
+
828
+ function filterCalendarEventsForSearch(events = [], query = '', limit = 20) {
829
+ const terms = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
830
+ if (!terms.length) return [];
831
+ return events
832
+ .filter((event) => {
833
+ const haystack = calendarSearchText(event);
834
+ return terms.every((term) => haystack.includes(term));
835
+ })
836
+ .slice(0, limit);
837
+ }
838
+
839
+ function compactCalendarEvent(event = {}) {
840
+ return {
841
+ id: event.id || event.event_id || '',
842
+ start: calendarEventTimeValue(event.start || event.start_time || event.startTime),
843
+ end: calendarEventTimeValue(event.end || event.end_time || event.endTime),
844
+ summary: event.summary || event.title || '(no title)',
845
+ location: event.location || '',
846
+ htmlLink: event.htmlLink || event.link || '',
847
+ ...(event.account_id ? { account_id: event.account_id } : {}),
848
+ ...(event.account_email ? { account_email: event.account_email } : {}),
849
+ };
850
+ }
851
+
852
+ function calendarCachePacket(events = [], meta = {}) {
853
+ return {
854
+ ok: true,
855
+ updatedAt: new Date().toISOString(),
856
+ source: meta.source || 'live',
857
+ days: meta.days || null,
858
+ count: events.length,
859
+ accounts: meta.accounts || [],
860
+ errors: meta.errors || [],
861
+ partial: Boolean(meta.partial),
862
+ events: events.map(compactCalendarEvent),
863
+ };
864
+ }
865
+
866
+ function writeCalendarEventsCache(events = [], meta = {}) {
867
+ const rows = Array.isArray(events) ? events : [];
868
+ const cachePath = calendarCachePath();
869
+ try {
870
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
871
+ fs.writeFileSync(cachePath, `${JSON.stringify(calendarCachePacket(rows, meta), null, 2)}\n`, 'utf8');
872
+ return cachePath;
873
+ } catch {
874
+ return '';
875
+ }
876
+ }
877
+
878
+ function readCalendarEventsCache() {
879
+ const cachePath = calendarCachePath();
880
+ try {
881
+ const packet = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
882
+ const events = Array.isArray(packet.events) ? packet.events : [];
883
+ return {
884
+ ok: true,
885
+ path: cachePath,
886
+ updatedAt: packet.updatedAt || '',
887
+ accounts: Array.isArray(packet.accounts) ? packet.accounts : [],
888
+ errors: Array.isArray(packet.errors) ? packet.errors : [],
889
+ partial: Boolean(packet.partial || packet.errors?.length),
890
+ events,
891
+ };
892
+ } catch {
893
+ return { ok: false, path: cachePath, updatedAt: '', events: [] };
894
+ }
895
+ }
896
+
897
+ function formatCalendarSearchResults(query, matches, {
898
+ json = false,
899
+ total = matches.length,
900
+ source = 'live',
901
+ liveOk = true,
902
+ cacheUpdatedAt = '',
903
+ error = '',
904
+ accounts = [],
905
+ errors = [],
906
+ partial = false,
907
+ } = {}) {
908
+ if (json) {
909
+ console.log(JSON.stringify({
910
+ ok: true,
911
+ query,
912
+ source,
913
+ liveOk,
914
+ cacheUpdatedAt,
915
+ error,
916
+ accounts,
917
+ errors,
918
+ partial: Boolean(partial || errors.length),
919
+ totalEvents: total,
920
+ matchCount: matches.length,
921
+ matches: matches.map(compactCalendarEvent),
922
+ }, null, 2));
923
+ return;
924
+ }
925
+
926
+ if (!matches.length) {
927
+ console.log(`No upcoming events matched "${query}".`);
928
+ return;
929
+ }
930
+
931
+ console.log(`Found ${matches.length} upcoming event(s) matching "${query}":\n`);
932
+ formatCalendarEvents('matching your search', matches);
933
+ }
934
+
935
+ function formatCalendarSearchError(query, message, { json = false, status = 0 } = {}) {
936
+ if (json) {
937
+ console.log(JSON.stringify({
938
+ ok: false,
939
+ query,
940
+ status,
941
+ error: message || 'Failed to fetch events',
942
+ totalEvents: 0,
943
+ matchCount: 0,
944
+ matches: [],
945
+ }, null, 2));
946
+ return;
947
+ }
948
+ console.error(`Error: ${message || 'Failed to fetch events'}`);
949
+ process.exit(1);
950
+ }
951
+
952
+ function parseLimit(args, fallback = 20) {
953
+ const idx = args.findIndex((arg) => arg === '--limit' || arg === '-n');
954
+ const raw = idx >= 0 ? args[idx + 1] : '';
955
+ const parsed = Number.parseInt(raw, 10);
956
+ return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 100) : fallback;
957
+ }
958
+
959
+ function argsWithoutLimit(args = []) {
960
+ const out = [];
961
+ for (let i = 0; i < args.length; i += 1) {
962
+ if (args[i] === '--limit' || args[i] === '-n') {
963
+ i += 1;
964
+ continue;
965
+ }
966
+ out.push(args[i]);
967
+ }
968
+ return out;
969
+ }
970
+
971
+ async function slackPersonalChannels(token) {
972
+ const result = await apiRequestJson('/integrations/slack/me/channels', {
973
+ method: 'GET',
974
+ token,
975
+ });
976
+ if (!result.ok) return [];
977
+ return result.data?.channels || result.data || [];
978
+ }
979
+
980
+ async function resolveSlackChannel(token, channel) {
981
+ const text = String(channel || '').trim();
982
+ if (!text) return '';
983
+ if (/^[A-Z][A-Z0-9]{5,}$/.test(text)) return text;
984
+ const wanted = text.replace(/^#/, '').toLowerCase();
985
+ const channels = await slackPersonalChannels(token);
986
+ const match = channels.find((ch) => String(ch.name || '').toLowerCase() === wanted || String(ch.id || '').toLowerCase() === wanted);
987
+ return match?.id || text;
988
+ }
989
+
990
+ function formatSlackMessages(messages) {
991
+ if (!messages.length) {
992
+ console.log('No Slack messages found.');
993
+ return;
994
+ }
995
+ console.log('─'.repeat(60));
996
+ for (const message of messages) {
997
+ const author = message.user_name || message.username || message.user || message.sender || 'unknown';
998
+ const ts = message.datetime || message.time || message.ts || message.created_at || '';
999
+ const text = String(message.text || message.content || message.message || '').replace(/\s+/g, ' ').trim();
1000
+ console.log(`${ts} ${author}: ${text || '(no text)'}`);
1001
+ }
1002
+ console.log('─'.repeat(60));
1003
+ }
1004
+
1005
+ function imessageDoctor() {
1006
+ const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
1007
+ const checks = {
1008
+ macos: process.platform === 'darwin',
1009
+ chat_db_exists: false,
1010
+ chat_db_readable: false,
1011
+ sqlite3_available: false,
1012
+ osascript_available: false,
1013
+ messages_automation: false,
1014
+ };
1015
+ const issues = [];
1016
+
1017
+ checks.chat_db_exists = fs.existsSync(chatDb);
1018
+ if (checks.chat_db_exists) {
1019
+ try {
1020
+ fs.accessSync(chatDb, fs.constants.R_OK);
1021
+ checks.chat_db_readable = true;
1022
+ } catch {
1023
+ issues.push('Messages database exists but is not readable. Grant Full Disk Access to this terminal or Atris.');
1024
+ }
1025
+ } else {
1026
+ issues.push('Messages database not found on this Mac.');
1027
+ }
1028
+
1029
+ checks.sqlite3_available = spawnSync('sqlite3', ['--version'], { encoding: 'utf8' }).status === 0;
1030
+ if (!checks.sqlite3_available) issues.push('sqlite3 is not available.');
1031
+
1032
+ checks.osascript_available = spawnSync('osascript', ['-e', 'return "ok"'], { encoding: 'utf8' }).status === 0;
1033
+ if (!checks.osascript_available) issues.push('osascript is not available.');
1034
+ if (checks.osascript_available) {
1035
+ checks.messages_automation = spawnSync('osascript', ['-e', 'tell application "Messages" to count services'], {
1036
+ encoding: 'utf8',
1037
+ timeout: 4000,
1038
+ }).status === 0;
1039
+ }
1040
+ if (!checks.messages_automation) issues.push('Messages automation permission is not available yet.');
1041
+
1042
+ if (!checks.macos) issues.push('Local iMessage requires macOS.');
1043
+
1044
+ const connected = checks.macos && checks.chat_db_exists && checks.chat_db_readable && checks.sqlite3_available && checks.osascript_available && checks.messages_automation;
1045
+ return {
1046
+ connected,
1047
+ provider: 'local_imessage',
1048
+ mode: 'local',
1049
+ checks,
1050
+ issues,
1051
+ next_step: connected
1052
+ ? 'iMessage is available on this Mac.'
1053
+ : 'Open System Settings -> Privacy & Security -> Full Disk Access and allow your terminal or Atris, then run this check again.',
1054
+ };
1055
+ }
1056
+
1057
+ function printImessageDoctor(result, json = false) {
1058
+ if (json) {
1059
+ console.log(JSON.stringify(result, null, 2));
1060
+ return;
1061
+ }
1062
+ console.log('iMessage local check\n');
1063
+ console.log(`Status: ${result.connected ? 'Connected on this Mac' : 'Needs permission or setup'}`);
1064
+ for (const [name, ok] of Object.entries(result.checks)) {
1065
+ console.log(` ${ok ? '✓' : '✗'} ${name.replace(/_/g, ' ')}`);
1066
+ }
1067
+ if (result.issues.length) {
1068
+ console.log('\nNext:');
1069
+ for (const issue of result.issues) console.log(` - ${issue}`);
1070
+ console.log(` - ${result.next_step}`);
1071
+ }
1072
+ }
1073
+
1074
+ function imessageRecent(handle, options = {}) {
1075
+ if (!handle) {
1076
+ console.error('Usage: atris imessage recent <phone-or-email> [--limit 20]');
1077
+ process.exit(1);
1078
+ }
1079
+ const doctor = imessageDoctor();
1080
+ if (!doctor.connected) {
1081
+ printImessageDoctor(doctor, Boolean(options.json));
1082
+ process.exit(1);
1083
+ }
1084
+
1085
+ const limit = Number(options.limit || 20);
1086
+ const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
1087
+ const sql = `
1088
+ SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') AS ts,
1089
+ CASE m.is_from_me WHEN 1 THEN 'me' ELSE h.id END AS sender,
1090
+ replace(replace(COALESCE(m.text,''), char(10), ' '), char(13), ' ') AS text
1091
+ FROM message m
1092
+ JOIN handle h ON h.rowid = m.handle_id
1093
+ WHERE h.id = '${String(handle).replace(/'/g, "''")}'
1094
+ ORDER BY m.date DESC
1095
+ LIMIT ${Math.max(1, Math.min(100, limit))};
1096
+ `;
1097
+ const result = spawnSync('sqlite3', ['-readonly', chatDb, sql], { encoding: 'utf8' });
1098
+ if (result.status !== 0) {
1099
+ console.error(result.stderr || 'Failed to read Messages database.');
1100
+ process.exit(1);
1101
+ }
1102
+ console.log(result.stdout.trim() || 'No recent messages found.');
1103
+ }
1104
+
1105
+ function escapeSqlString(value) {
1106
+ return String(value || '').replace(/'/g, "''");
1107
+ }
1108
+
1109
+ function normalizeLookupKey(value) {
1110
+ return String(value || '')
1111
+ .toLowerCase()
1112
+ .replace(/[^a-z0-9@+]+/g, ' ')
1113
+ .replace(/\s+/g, ' ')
1114
+ .trim();
1115
+ }
1116
+
1117
+ function normalizeImessageHandle(value) {
1118
+ const raw = String(value || '').trim();
1119
+ if (!raw) return '';
1120
+ if (raw.includes('@')) return raw;
1121
+ const digits = raw.replace(/[^\d]/g, '');
1122
+ if (digits.length === 10) return `+1${digits}`;
1123
+ if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
1124
+ return raw;
1125
+ }
1126
+
1127
+ function normalizeContactLabel(value) {
1128
+ return String(value || '')
1129
+ .replace(/^_\$!<|>!\$_$/g, '')
1130
+ .replace(/^\$!<|>!\$$/g, '')
1131
+ .trim() || 'other';
1132
+ }
1133
+
1134
+ function imessageLookupCachePath() {
1135
+ return path.join(stateHome(), '.atris', 'cache', 'imessage-contacts.json');
1136
+ }
1137
+
1138
+ function readImessageLookupCache() {
1139
+ const cachePath = imessageLookupCachePath();
1140
+ try {
1141
+ return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
1142
+ } catch {
1143
+ return { version: 1, entries: {} };
1144
+ }
1145
+ }
1146
+
1147
+ function writeImessageLookupCache(cache) {
1148
+ const cachePath = imessageLookupCachePath();
1149
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
1150
+ fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
1151
+ return cachePath;
1152
+ }
1153
+
1154
+ function readLatestOutgoingImessage(handle, sinceMs) {
1155
+ const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
1156
+ const sinceUnix = Math.max(0, Math.floor(Number(sinceMs || Date.now()) / 1000) - 5);
1157
+ const sql = `
1158
+ SELECT m.rowid,
1159
+ datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') AS ts,
1160
+ m.is_sent,
1161
+ m.is_delivered,
1162
+ m.is_finished,
1163
+ COALESCE(m.error, 0) AS error,
1164
+ length(COALESCE(m.text,'')) AS text_len
1165
+ FROM message m
1166
+ JOIN handle h ON h.rowid = m.handle_id
1167
+ WHERE h.id = '${escapeSqlString(handle)}'
1168
+ AND m.is_from_me = 1
1169
+ AND (m.date/1000000000 + 978307200) >= ${sinceUnix}
1170
+ ORDER BY m.date DESC
1171
+ LIMIT 1;
1172
+ `;
1173
+ const result = spawnSync('sqlite3', ['-readonly', chatDb, sql], { encoding: 'utf8' });
1174
+ if (result.status !== 0) {
1175
+ return {
1176
+ matched: false,
1177
+ error: (result.stderr || 'Failed to verify latest outgoing iMessage.').trim(),
1178
+ };
1179
+ }
1180
+ const row = String(result.stdout || '').trim();
1181
+ if (!row) {
1182
+ return {
1183
+ matched: false,
1184
+ error: 'No outgoing Messages row found after send.',
1185
+ };
1186
+ }
1187
+ const [rowid, ts, isSent, isDelivered, isFinished, messageError, textLen] = row.split('|');
1188
+ return {
1189
+ matched: true,
1190
+ rowid,
1191
+ timestamp: ts,
1192
+ is_sent: Number(isSent) === 1,
1193
+ is_delivered: Number(isDelivered) === 1,
1194
+ is_finished: Number(isFinished) === 1,
1195
+ message_error: Number(messageError) || 0,
1196
+ text_readable: Number(textLen) > 0,
1197
+ };
1198
+ }
1199
+
1200
+ function imessageVerifyLatestOutgoing(handle, sinceMs, options = {}) {
1201
+ const timeoutMs = Math.max(0, Number(options.timeoutMs || 2000));
1202
+ const intervalMs = Math.max(25, Number(options.intervalMs || 150));
1203
+ const startedAt = Date.now();
1204
+ let latest = readLatestOutgoingImessage(handle, sinceMs);
1205
+ while (
1206
+ latest.matched
1207
+ && latest.message_error === 0
1208
+ && !(latest.is_sent || latest.is_delivered || latest.is_finished)
1209
+ && Date.now() - startedAt < timeoutMs
1210
+ ) {
1211
+ sleepSync(intervalMs);
1212
+ latest = readLatestOutgoingImessage(handle, sinceMs);
1213
+ }
1214
+ return {
1215
+ ...latest,
1216
+ settled: Boolean(latest.matched && latest.message_error === 0 && (latest.is_sent || latest.is_delivered || latest.is_finished)),
1217
+ waited_ms: Date.now() - startedAt,
1218
+ };
1219
+ }
1220
+
1221
+ function parseImessageLookupArgs(args) {
1222
+ const options = {
1223
+ json: false,
1224
+ refresh: false,
1225
+ name: '',
1226
+ maxAgeMs: 7 * 24 * 60 * 60 * 1000,
1227
+ };
1228
+ const positional = [];
1229
+ for (let i = 0; i < args.length; i += 1) {
1230
+ const arg = args[i];
1231
+ if (arg === '--json') {
1232
+ options.json = true;
1233
+ } else if (arg === '--refresh') {
1234
+ options.refresh = true;
1235
+ } else if (arg === '--name' || arg === '--query') {
1236
+ options.name = args[i + 1] || '';
1237
+ i += 1;
1238
+ } else if (arg === '--max-age-minutes') {
1239
+ options.maxAgeMs = Math.max(0, Number(args[i + 1] || 0) * 60 * 1000);
1240
+ i += 1;
1241
+ } else {
1242
+ positional.push(arg);
1243
+ }
1244
+ }
1245
+ if (!options.name && positional.length) options.name = positional.join(' ');
1246
+ options.name = String(options.name || '').trim();
1247
+ return options;
1248
+ }
1249
+
1250
+ const CONTACT_LOOKUP_SCRIPT = `
1251
+ function run(argv) {
1252
+ const query = String(argv[0] || '').trim();
1253
+ const Contacts = Application('Contacts');
1254
+ const selfAlias = /^(me|myself|self|my number|my phone|my contact)$/i.test(query);
1255
+ function safe(fn) {
1256
+ try {
1257
+ const value = fn();
1258
+ return value === null || value === undefined ? '' : String(value);
1259
+ } catch (error) {
1260
+ return '';
1261
+ }
1262
+ }
1263
+ function propertyRows(rows) {
1264
+ const out = [];
1265
+ try {
1266
+ const list = rows();
1267
+ for (let i = 0; i < list.length; i += 1) {
1268
+ const item = list[i];
1269
+ out.push({
1270
+ label: safe(function () { return item.label(); }),
1271
+ value: safe(function () { return item.value(); })
1272
+ });
1273
+ }
1274
+ } catch (error) {}
1275
+ return out.filter(function (row) { return row.value; });
1276
+ }
1277
+ function contactRow(person) {
1278
+ return {
1279
+ id: safe(function () { return person.id(); }),
1280
+ name: safe(function () { return person.name(); }),
1281
+ first_name: safe(function () { return person.firstName(); }),
1282
+ last_name: safe(function () { return person.lastName(); }),
1283
+ organization: safe(function () { return person.organization(); }),
1284
+ phones: propertyRows(function () { return person.phones(); }),
1285
+ emails: propertyRows(function () { return person.emails(); })
1286
+ };
1287
+ }
1288
+ let people = [];
1289
+ if (selfAlias) {
1290
+ if (Contacts.myCard.exists()) people = [Contacts.myCard];
1291
+ } else {
1292
+ people = Contacts.people.whose({ name: { _contains: query } })();
1293
+ if (!people.length && query.indexOf(' ') > -1) {
1294
+ people = Contacts.people.whose({ name: { _contains: query.split(/\\s+/)[0] } })();
1295
+ }
1296
+ }
1297
+ const rows = [];
1298
+ const limit = Math.min(20, people.length);
1299
+ for (let i = 0; i < limit; i += 1) rows.push(contactRow(people[i]));
1300
+ return JSON.stringify({ ok: true, query: query, self_alias: selfAlias, contacts_count: people.length, matches: rows });
1301
+ }
1302
+ `;
1303
+
1304
+ function scoreImessageContact(query, match, selfAlias = false) {
1305
+ if (selfAlias) return 100;
1306
+ const q = normalizeLookupKey(query);
1307
+ const name = normalizeLookupKey(match.name);
1308
+ if (!q || !name) return 0;
1309
+ if (name === q) return 95;
1310
+ if (name.includes(q)) return 80;
1311
+ const tokens = q.split(' ').filter(Boolean);
1312
+ const nameTokens = new Set(name.split(' ').filter(Boolean));
1313
+ if (tokens.length && tokens.every((token) => nameTokens.has(token))) return 75;
1314
+ if (tokens.length === 1 && nameTokens.has(tokens[0])) return 65;
1315
+ return 40;
1316
+ }
1317
+
1318
+ function shapeImessageLookupPayload(raw, options, cached = false, cachedAt = null) {
1319
+ const query = options.name;
1320
+ const selfAlias = Boolean(raw.self_alias);
1321
+ const matches = (raw.matches || [])
1322
+ .map((match) => {
1323
+ const phones = (match.phones || [])
1324
+ .map((phone) => ({
1325
+ label: normalizeContactLabel(phone.label),
1326
+ value: String(phone.value || '').trim(),
1327
+ handle: normalizeImessageHandle(phone.value),
1328
+ }))
1329
+ .filter((phone) => phone.handle);
1330
+ const emails = (match.emails || [])
1331
+ .map((email) => ({
1332
+ label: normalizeContactLabel(email.label),
1333
+ value: String(email.value || '').trim(),
1334
+ handle: String(email.value || '').trim(),
1335
+ }))
1336
+ .filter((email) => email.handle);
1337
+ const handles = [
1338
+ ...phones.map((phone) => ({ type: 'phone', label: phone.label, handle: phone.handle })),
1339
+ ...emails.map((email) => ({ type: 'email', label: email.label, handle: email.handle })),
1340
+ ];
1341
+ return {
1342
+ id: match.id || '',
1343
+ name: match.name || [match.first_name, match.last_name].filter(Boolean).join(' '),
1344
+ phones,
1345
+ emails,
1346
+ handles,
1347
+ primary_handle: handles[0]?.handle || '',
1348
+ score: scoreImessageContact(query, match, selfAlias),
1349
+ };
1350
+ })
1351
+ .filter((match) => match.primary_handle)
1352
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
1353
+ const exactMatches = matches.filter((match) => normalizeLookupKey(match.name) === normalizeLookupKey(query));
1354
+ const primary = matches.length === 1
1355
+ ? matches[0]
1356
+ : exactMatches.length === 1
1357
+ ? exactMatches[0]
1358
+ : null;
1359
+ return {
1360
+ ok: true,
1361
+ action: 'imessage_lookup',
1362
+ provider: 'local_contacts',
1363
+ query,
1364
+ cached,
1365
+ cached_at: cachedAt,
1366
+ cache_path: imessageLookupCachePath(),
1367
+ match_count: matches.length,
1368
+ unique: Boolean(primary),
1369
+ ambiguous: matches.length > 1 && !primary,
1370
+ primary: primary ? {
1371
+ name: primary.name,
1372
+ handle: primary.primary_handle,
1373
+ handles: primary.handles,
1374
+ } : null,
1375
+ matches,
1376
+ };
1377
+ }
1378
+
1379
+ function printImessageLookupPayload(payload, json = false) {
1380
+ if (json) {
1381
+ console.log(JSON.stringify(payload, null, 2));
1382
+ return;
1383
+ }
1384
+ if (!payload.ok) {
1385
+ console.error(payload.error || 'Contact lookup failed.');
1386
+ return;
1387
+ }
1388
+ if (!payload.matches.length) {
1389
+ console.log(`No iMessage contacts found for "${payload.query}".`);
1390
+ return;
1391
+ }
1392
+ if (payload.primary) {
1393
+ console.log(`${payload.primary.name}: ${payload.primary.handle}${payload.cached ? ' (cached)' : ''}`);
1394
+ return;
1395
+ }
1396
+ console.log(`Multiple matches for "${payload.query}":`);
1397
+ for (const match of payload.matches.slice(0, 8)) {
1398
+ console.log(` - ${match.name}: ${match.primary_handle}`);
1399
+ }
1400
+ }
1401
+
1402
+ function imessageLookup(args = []) {
1403
+ const options = parseImessageLookupArgs(args);
1404
+ if (!options.name) {
1405
+ printImessageLookupPayload({
1406
+ ok: false,
1407
+ action: 'imessage_lookup',
1408
+ error: 'Usage: atris imessage lookup --name <contact-name> [--json] [--refresh]',
1409
+ }, options.json);
1410
+ process.exit(1);
1411
+ }
1412
+
1413
+ const key = normalizeLookupKey(options.name);
1414
+ const cache = readImessageLookupCache();
1415
+ const entry = cache.entries?.[key];
1416
+ if (!options.refresh && entry && Date.now() - Number(entry.cached_at || 0) <= options.maxAgeMs) {
1417
+ printImessageLookupPayload(shapeImessageLookupPayload(entry.raw, options, true, entry.cached_at), options.json);
1418
+ return;
1419
+ }
1420
+
1421
+ const result = spawnSync('osascript', ['-l', 'JavaScript', '-e', CONTACT_LOOKUP_SCRIPT, options.name], {
1422
+ encoding: 'utf8',
1423
+ timeout: 6000,
1424
+ });
1425
+ if (result.status !== 0) {
1426
+ printImessageLookupPayload({
1427
+ ok: false,
1428
+ action: 'imessage_lookup',
1429
+ query: options.name,
1430
+ error: (result.stderr || 'Contacts lookup failed. Grant Contacts automation permission to this terminal or Atris.').trim(),
1431
+ }, options.json);
1432
+ process.exit(1);
1433
+ }
1434
+
1435
+ let raw;
1436
+ try {
1437
+ raw = JSON.parse(String(result.stdout || '{}'));
1438
+ } catch {
1439
+ printImessageLookupPayload({
1440
+ ok: false,
1441
+ action: 'imessage_lookup',
1442
+ query: options.name,
1443
+ error: 'Contacts lookup returned invalid JSON.',
1444
+ }, options.json);
1445
+ process.exit(1);
1446
+ }
1447
+
1448
+ const cachedAt = Date.now();
1449
+ const latestCache = readImessageLookupCache();
1450
+ latestCache.version = 1;
1451
+ latestCache.entries = latestCache.entries || {};
1452
+ latestCache.entries[key] = { cached_at: cachedAt, raw };
1453
+ writeImessageLookupCache(latestCache);
1454
+ printImessageLookupPayload(shapeImessageLookupPayload(raw, options, false, cachedAt), options.json);
1455
+ }
1456
+
1457
+ function parseImessageSendArgs(args) {
1458
+ const outbound = extractOutboundGateOptions(args);
1459
+ const options = {
1460
+ approved: false,
1461
+ json: false,
1462
+ receipt: false,
1463
+ to: '',
1464
+ text: '',
1465
+ outboundGate: outbound.gate,
1466
+ };
1467
+ const positional = [];
1468
+ for (let i = 0; i < outbound.args.length; i += 1) {
1469
+ const arg = outbound.args[i];
1470
+ if (arg === '--approved' || arg === '--confirm-approved') {
1471
+ options.approved = true;
1472
+ } else if (arg === '--json') {
1473
+ options.json = true;
1474
+ } else if (arg === '--receipt') {
1475
+ options.receipt = true;
1476
+ } else if (arg === '--to' || arg === '--handle') {
1477
+ options.to = outbound.args[i + 1] || '';
1478
+ i += 1;
1479
+ } else if (arg === '--text' || arg === '--message') {
1480
+ options.text = outbound.args[i + 1] || '';
1481
+ i += 1;
1482
+ } else {
1483
+ positional.push(arg);
1484
+ }
1485
+ }
1486
+ if (!options.to && positional.length) options.to = positional.shift() || '';
1487
+ if (!options.text && positional.length) options.text = positional.join(' ');
1488
+ options.to = normalizeImessageHandle(options.to);
1489
+ options.text = String(options.text || '').trim();
1490
+ return options;
1491
+ }
1492
+
1493
+ function printImessageSendPayload(payload, json = false) {
1494
+ if (json) {
1495
+ console.log(JSON.stringify(payload, null, 2));
1496
+ return;
1497
+ }
1498
+ if (payload.ok) {
1499
+ console.log(`Sent iMessage to ${payload.to}.`);
1500
+ if (payload.receipt_path) console.log(`Receipt: ${payload.receipt_path}`);
1501
+ } else {
1502
+ console.error(payload.error || 'Failed to send iMessage.');
1503
+ }
1504
+ }
1505
+
1506
+ function writeImessageSendReceipt(payload) {
1507
+ const atrisDir = path.join(process.cwd(), 'atris');
1508
+ if (!fs.existsSync(atrisDir)) return '';
1509
+ const runsDir = path.join(atrisDir, 'runs');
1510
+ fs.mkdirSync(runsDir, { recursive: true });
1511
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1512
+ const receiptPath = path.join(runsDir, `imessage-send-${stamp}.md`);
1513
+ const lines = [
1514
+ '# iMessage Send Receipt',
1515
+ '',
1516
+ `- Sent at: ${payload.sent_at}`,
1517
+ `- Provider: ${payload.provider}`,
1518
+ `- To: ${payload.to}`,
1519
+ `- Text: ${payload.text}`,
1520
+ `- Doctor connected: ${payload.doctor?.connected === true}`,
1521
+ `- Send exit: ${payload.osascript?.status}`,
1522
+ `- DB verified: ${payload.db_verification?.matched === true}`,
1523
+ `- DB settled: ${payload.db_verification?.settled === true}`,
1524
+ ];
1525
+ fs.writeFileSync(receiptPath, `${lines.join('\n')}\n`, 'utf8');
1526
+ return receiptPath;
1527
+ }
1528
+
1529
+ function imessageSend(args = []) {
1530
+ const options = parseImessageSendArgs(args);
1531
+ const basePayload = {
1532
+ ok: false,
1533
+ action: 'imessage_send',
1534
+ provider: 'local_imessage',
1535
+ to: options.to,
1536
+ text: options.text,
1537
+ approved: options.approved,
1538
+ };
1539
+
1540
+ if (!options.to || (!options.text && !options.outboundGate.bodyFile)) {
1541
+ printImessageSendPayload({
1542
+ ...basePayload,
1543
+ error: 'Usage: atris imessage send --to <phone-or-email> --text <message> --approved [--json] [--receipt]',
1544
+ }, options.json);
1545
+ process.exit(1);
1546
+ }
1547
+
1548
+ if (!options.approved) {
1549
+ printImessageSendPayload({
1550
+ ...basePayload,
1551
+ error: 'Refusing to send without --approved after the exact recipient and exact text are confirmed.',
1552
+ }, options.json);
1553
+ process.exit(1);
1554
+ }
1555
+
1556
+ const gateResult = assertOutboundSendAllowedOrExit('imessage', options.text, options.outboundGate, {
1557
+ json: options.json,
1558
+ payload: basePayload,
1559
+ });
1560
+ options.text = gateResult.body;
1561
+ basePayload.text = options.text;
1562
+ if (!options.text) {
1563
+ printImessageSendPayload({
1564
+ ...basePayload,
1565
+ error: 'Usage: atris imessage send --to <phone-or-email> --text <message> --approved [--json] [--receipt]',
1566
+ }, options.json);
1567
+ process.exit(1);
1568
+ }
1569
+
1570
+ const doctor = imessageDoctor();
1571
+ if (!doctor.connected) {
1572
+ printImessageSendPayload({
1573
+ ...basePayload,
1574
+ doctor,
1575
+ error: 'iMessage is not available on this Mac.',
1576
+ }, options.json);
1577
+ process.exit(1);
1578
+ }
1579
+
1580
+ const sendStartedAt = Date.now();
1581
+ const result = spawnSync('osascript', [
1582
+ '-e', 'on run argv',
1583
+ '-e', 'set targetHandle to item 1 of argv',
1584
+ '-e', 'set messageText to item 2 of argv',
1585
+ '-e', 'tell application "Messages"',
1586
+ '-e', 'set targetService to 1st service whose service type = iMessage',
1587
+ '-e', 'set targetBuddy to buddy targetHandle of targetService',
1588
+ '-e', 'send messageText to targetBuddy',
1589
+ '-e', 'end tell',
1590
+ '-e', 'return targetHandle',
1591
+ '-e', 'end run',
1592
+ options.to,
1593
+ options.text,
1594
+ ], {
1595
+ encoding: 'utf8',
1596
+ timeout: 10000,
1597
+ });
1598
+
1599
+ const payload = {
1600
+ ...basePayload,
1601
+ ok: result.status === 0,
1602
+ sent_at: new Date().toISOString(),
1603
+ doctor: {
1604
+ connected: doctor.connected,
1605
+ checks: doctor.checks,
1606
+ },
1607
+ osascript: {
1608
+ status: result.status,
1609
+ signal: result.signal || null,
1610
+ stdout: String(result.stdout || '').trim(),
1611
+ stderr: String(result.stderr || '').trim(),
1612
+ },
1613
+ };
1614
+
1615
+ if (payload.ok) {
1616
+ payload.db_verification = imessageVerifyLatestOutgoing(options.to, sendStartedAt);
1617
+ }
1618
+
1619
+ if (payload.ok && options.receipt) {
1620
+ payload.receipt_path = writeImessageSendReceipt(payload);
1621
+ }
1622
+
1623
+ if (!payload.ok) {
1624
+ payload.error = payload.osascript.stderr || 'Messages AppleScript send failed.';
1625
+ }
1626
+
1627
+ printImessageSendPayload(payload, options.json);
1628
+ if (!payload.ok) process.exit(1);
1629
+ }
1630
+
1631
+ module.exports = {
1632
+ googleContext,
1633
+ loadCredentials,
1634
+ ensureValidCredentials,
1635
+ apiRequestJson,
1636
+ takeContext,
1637
+ GMAIL_CONNECT_POLL_MS,
1638
+ GMAIL_CONNECT_TIMEOUT_MS,
1639
+ GMAIL_SEND_USAGE,
1640
+ gmailVerdictsPath,
1641
+ appendGmailVerdicts,
1642
+ readGmailVerdicts,
1643
+ printGmailVerdicts,
1644
+ parseGmailTriageArgs,
1645
+ parseGmailVerdictsArgs,
1646
+ gmailAccountStatePath,
1647
+ googleAccountsCapability,
1648
+ prepareMailDefaults,
1649
+ resolveGmailAccountId,
1650
+ gmailAccountIdentity,
1651
+ sortGmailAccounts,
1652
+ formatGmailAccountRows,
1653
+ fetchGmailAccounts,
1654
+ resolveAccountSelector,
1655
+ promptForGmailAccount,
1656
+ getAuth,
1657
+ getAuthToken,
1658
+ extractGmailMailboxAccount,
1659
+ parseGmailArgs,
1660
+ gmailTriageVerdict,
1661
+ parseGmailSendArgs,
1662
+ parseGmailVoiceArgs,
1663
+ editGmailVoice,
1664
+ openGmailAuthUrl,
1665
+ googleOldServer,
1666
+ googleProductNames,
1667
+ googleProducts,
1668
+ googleFailure,
1669
+ saveGoogleDefault,
1670
+ googleSignInRetry,
1671
+ googleSignInFailure,
1672
+ googleSignInErrorLines,
1673
+ localDayBounds,
1674
+ localDateBounds,
1675
+ compareCalendarEvents,
1676
+ calendarReadMetadata,
1677
+ formatCalendarEvents,
1678
+ parseCalendarSearchArgs,
1679
+ filterCalendarEventsForSearch,
1680
+ writeCalendarEventsCache,
1681
+ readCalendarEventsCache,
1682
+ formatCalendarSearchResults,
1683
+ formatCalendarSearchError,
1684
+ parseLimit,
1685
+ argsWithoutLimit,
1686
+ resolveSlackChannel,
1687
+ formatSlackMessages,
1688
+ imessageDoctor,
1689
+ printImessageDoctor,
1690
+ imessageRecent,
1691
+ normalizeImessageHandle,
1692
+ scoreImessageContact,
1693
+ imessageLookup,
1694
+ imessageSend,
1695
+ };