atris 3.80.1 → 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 (66) 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/autoland.js +25 -26
  21. package/commands/autopilot-front.js +46 -1
  22. package/commands/autopilot.js +55 -25
  23. package/commands/clean.js +22 -11
  24. package/commands/computer.js +1 -1
  25. package/commands/doc-health.js +48 -1
  26. package/commands/improve.js +16 -3
  27. package/commands/init.js +4 -0
  28. package/commands/integrations.js +69 -1616
  29. package/commands/mission.js +109 -13
  30. package/commands/release.js +41 -10
  31. package/commands/status.js +1 -1
  32. package/commands/sync.js +12 -8
  33. package/commands/task.js +144 -118
  34. package/commands/unknowns.js +9 -5
  35. package/commands/visualize.js +1 -12
  36. package/commands/workflow.js +4 -3
  37. package/commands/worktree.js +68 -1602
  38. package/commands/xp.js +23 -2113
  39. package/commands/youtube.js +192 -3110
  40. package/lib/business-core.js +3 -4
  41. package/lib/campaign-run-lock.js +12 -1
  42. package/lib/engine-ask.js +1 -1
  43. package/lib/engine-receipt-sweep.js +64 -0
  44. package/lib/file-lock.js +175 -0
  45. package/lib/fleet.js +1 -1
  46. package/lib/integrations-core.js +1695 -0
  47. package/lib/json-file.js +22 -1
  48. package/lib/loop-doctor.js +36 -2
  49. package/lib/map-refs.js +68 -8
  50. package/lib/member-engine.js +1 -1
  51. package/lib/member-map-fold.js +49 -11
  52. package/lib/member-map-inbox.js +2 -1
  53. package/lib/mission-core.js +9 -2
  54. package/lib/recall-exam.js +171 -0
  55. package/lib/roster-markdown.js +2 -2
  56. package/lib/runner-command.js +5 -5
  57. package/lib/task-db.js +105 -37
  58. package/lib/taste-notes.js +35 -0
  59. package/lib/team-lineup.js +3 -3
  60. package/lib/todo.js +2 -0
  61. package/lib/wiki.js +11 -3
  62. package/lib/worktree-core.js +1517 -0
  63. package/lib/xp-core.js +2172 -0
  64. package/lib/youtube-core.js +3178 -0
  65. package/lib/zen-apply.js +3 -1
  66. package/package.json +2 -2
@@ -23,450 +23,79 @@
23
23
  * atris slack search <query> [--limit 20] - Search Slack messages
24
24
  */
25
25
 
26
- const { loadCredentials, ensureValidCredentials } = require('../utils/auth');
27
- const { apiRequestJson: rawApiRequestJson } = require('../utils/api');
28
- const { AsyncLocalStorage } = require('async_hooks');
29
- const { createHash } = require('crypto');
30
- const { resolveBusiness } = require('../lib/business-target');
31
- const googleContext = new AsyncLocalStorage();
32
-
33
- function apiRequestJson(url, options = {}) {
34
- const context = googleContext.getStore();
35
- if (!context || !/^\/integrations\/(gmail|google-calendar|google)(?:\/|$)/.test(url)) return rawApiRequestJson(url, options);
36
- const next = { ...options };
37
- if (context.business) next.headers = { ...(options.headers || {}), 'X-Business-Id': context.business };
38
- if (context.calendarAccount && url.startsWith('/integrations/google-calendar/')) {
39
- if ((options.method || 'GET') === 'GET' || options.method === 'DELETE') {
40
- url += `${url.includes('?') ? '&' : '?'}account_id=${encodeURIComponent(context.calendarAccount)}`;
41
- } else next.body = { ...(options.body || {}), account_id: context.calendarAccount };
42
- }
43
- if (context.serverDefaults && !context.mailAccount && url.startsWith('/integrations/gmail/') && !/\/accounts|\/start/.test(url)) {
44
- const parts = url.split('?');
45
- if (parts[1]) { const q = new URLSearchParams(parts[1]); q.delete('account_id'); url = parts[0] + (q.toString() ? `?${q}` : ''); }
46
- if (next.body && Object.hasOwn(next.body, 'account_id')) { next.body = { ...next.body }; delete next.body.account_id; }
47
- }
48
- return rawApiRequestJson(url, next);
49
- }
50
-
51
- function takeContext(args) {
52
- const rest = []; let businessFlag; let calendarAccount; let mailAccount;
53
- for (let i = 0; i < args.length; i += 1) {
54
- const arg = String(args[i]);
55
- if (arg === '--business' || arg.startsWith('--business=')) {
56
- businessFlag = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++i];
57
- if (!businessFlag || String(businessFlag).startsWith('--')) throw new Error('--business needs a business slug or id');
58
- } else if (arg === '--account' || arg.startsWith('--account=')) {
59
- const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++i];
60
- if (!value || String(value).startsWith('--')) throw new Error('--account needs an account name or email');
61
- calendarAccount = mailAccount = String(value);
62
- rest.push('--account', String(value));
63
- } else rest.push(args[i]);
64
- }
65
- const business = resolveBusiness(businessFlag === undefined ? {} : { business: businessFlag });
66
- if (business && business.error) throw new Error(business.error);
67
- return { rest, business: business && business.id, explicitBusiness: businessFlag !== undefined, calendarAccount, mailAccount };
68
- }
69
-
26
+ const fs = require('fs');
27
+ const path = require('path');
70
28
  const {
71
29
  assertOutboundSendAllowedOrExit,
72
- extractOutboundGateOptions,
73
30
  prepareOutboundSendTextOrExit,
74
31
  } = require('../lib/outbound-send-gate');
75
- const fs = require('fs');
76
- const os = require('os');
77
- const path = require('path');
78
- const { stateHome } = require('../lib/state-home');
79
- const readline = require('readline');
80
- const { spawnSync } = require('child_process');
81
- const { sleepSync } = require('../lib/sleep-sync');
82
-
83
- const CALENDAR_CACHE_PATH = path.join(stateHome(), '.atris', 'calendar-events-cache.json');
84
- const GMAIL_CONNECT_POLL_MS = 3000;
85
- const GMAIL_CONNECT_TIMEOUT_MS = 3 * 60 * 1000;
86
- const GMAIL_SEND_USAGE = 'usage: atris gmail send <to> <subject> <body...> --approved [--body-file <path>] [--account <id>]';
87
- const GMAIL_VOICE_USAGE = 'usage: atris gmail voice [account] [--clear]';
88
- const GMAIL_TRIAGE_USAGE = 'usage: atris gmail triage [--account <id>] [--limit N]';
89
- const GMAIL_VERDICTS_USAGE = 'usage: atris gmail verdicts [--summary] [--account <id>] [--limit N]';
90
- const GMAIL_BULK_DOMAINS = [
91
- 'campaign-archive.com',
92
- 'constantcontact.com',
93
- 'hubspotemail.net',
94
- 'mailchimp.com',
95
- 'mailchimpapp.net',
96
- 'mailgun.org',
97
- 'sendgrid.net',
98
- 'substack.com',
99
- ];
100
-
101
- function gmailVerdictsPath(root = process.cwd()) {
102
- return path.join(root, '.atris', 'state', 'gmail-verdicts.jsonl');
103
- }
104
-
105
- function appendGmailVerdicts(verdicts, options = {}) {
106
- const rows = Array.isArray(verdicts) ? verdicts : [];
107
- if (!rows.length) return;
108
- const filePath = options.filePath || gmailVerdictsPath(options.root);
109
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
110
- const lines = rows.map((row) => JSON.stringify({
111
- ts: row.ts || new Date().toISOString(),
112
- account: String(row.account || ''),
113
- verdict: row.verdict === 'keep' ? 'keep' : 'archive',
114
- message_id: String(row.message_id || ''),
115
- ...(row.from ? { from: row.from } : {}),
116
- ...(row.subject ? { subject: row.subject } : {}),
117
- ...(typeof row.reason === 'string' && row.reason ? { reason: row.reason } : {}),
118
- }));
119
- fs.appendFileSync(filePath, `${lines.join('\n')}\n`, 'utf8');
120
- }
121
-
122
- function readGmailVerdicts(options = {}) {
123
- const filePath = options.filePath || gmailVerdictsPath(options.root);
124
- let lines;
125
- try {
126
- lines = fs.readFileSync(filePath, 'utf8').split('\n').filter(Boolean);
127
- } catch (error) {
128
- if (error.code === 'ENOENT') return [];
129
- throw error;
130
- }
131
- const account = String(options.account || '').trim();
132
- const limit = options.limit === null ? null : (Number.isInteger(options.limit) ? options.limit : 20);
133
- const rows = lines.reduce((matches, line) => {
134
- try {
135
- const row = JSON.parse(line);
136
- if (!account || row.account === account) matches.push(row);
137
- } catch {}
138
- return matches;
139
- }, []).reverse();
140
- return limit === null ? rows : rows.slice(0, limit);
141
- }
142
-
143
- function summarizeGmailVerdicts(rows = []) {
144
- const groups = new Map();
145
- for (const row of rows) {
146
- const date = String(row.ts || '').slice(0, 10);
147
- const account = String(row.account || '');
148
- if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue;
149
- const key = `${date}\n${account}`;
150
- const summary = groups.get(key) || { date, account, keep: 0, archive: 0 };
151
- summary[row.verdict === 'keep' ? 'keep' : 'archive'] += 1;
152
- groups.set(key, summary);
153
- }
154
- return [...groups.values()].sort((left, right) => (
155
- right.date.localeCompare(left.date) || left.account.localeCompare(right.account)
156
- ));
157
- }
158
-
159
- function printGmailVerdicts(options = {}) {
160
- const rows = readGmailVerdicts(options.summary ? { ...options, limit: null } : options);
161
- if (options.summary) {
162
- const summaries = summarizeGmailVerdicts(rows);
163
- if (!summaries.length) {
164
- console.log('no gmail verdicts found.');
165
- return summaries;
166
- }
167
- for (const row of summaries) {
168
- console.log(`${row.date} account ${row.account}, keep ${row.keep}, archive ${row.archive}`);
169
- }
170
- return summaries;
171
- }
172
- if (!rows.length) {
173
- console.log('no gmail verdicts found.');
174
- return rows;
175
- }
176
- for (const row of rows) {
177
- const details = [
178
- row.reason && `reason ${row.reason}`,
179
- row.from && `from ${row.from}`,
180
- row.subject && `subject ${row.subject}`,
181
- ].filter(Boolean);
182
- console.log(`${row.ts} ${row.verdict || 'archive'} ${row.message_id} account ${row.account}${details.length ? `, ${details.join(', ')}` : ''}`);
183
- }
184
- return rows;
185
- }
186
-
187
- function parseGmailTriageArgs(args = []) {
188
- let accountId = null;
189
- let limit = 25;
190
- for (let i = 0; i < args.length; i += 1) {
191
- const arg = args[i];
192
- const value = String(args[i + 1] || '').trim();
193
- if (arg === '--account' && value && !value.startsWith('--')) accountId = value;
194
- else if (arg === '--limit' && /^\d+$/.test(value) && Number(value) > 0) limit = Number(value);
195
- else {
196
- console.error(GMAIL_TRIAGE_USAGE);
197
- process.exit(1);
198
- }
199
- i += 1;
200
- }
201
- return { accountId, limit };
202
- }
203
-
204
- function parseGmailVerdictsArgs(args = []) {
205
- let account = null;
206
- let limit = 20;
207
- let summary = false;
208
- for (let i = 0; i < args.length; i += 1) {
209
- const arg = args[i];
210
- const value = String(args[i + 1] || '').trim();
211
- if (arg === '--summary') {
212
- summary = true;
213
- continue;
214
- }
215
- if (arg === '--account' && value && !value.startsWith('--')) account = value;
216
- else if (arg === '--limit' && /^\d+$/.test(value) && Number(value) > 0) limit = Number(value);
217
- else {
218
- console.error(GMAIL_VERDICTS_USAGE);
219
- process.exit(1);
220
- }
221
- i += 1;
222
- }
223
- return { account, limit, summary };
224
- }
225
-
226
- function gmailAccountStatePath() {
227
- return process.env.ATRIS_GMAIL_ACCOUNT_FILE
228
- || path.join(stateHome(), '.atris', 'gmail-account.json');
229
- }
230
-
231
- function readGmailStickyAccount() {
232
- try {
233
- const data = JSON.parse(fs.readFileSync(gmailAccountStatePath(), 'utf8'));
234
- const id = String(data.account_id || '').trim();
235
- return id || null;
236
- } catch {
237
- return null;
238
- }
239
- }
240
-
241
- function writeGmailStickyAccount(accountId) {
242
- const filePath = gmailAccountStatePath();
243
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
244
- fs.writeFileSync(filePath, `${JSON.stringify({ account_id: accountId }, null, 2)}\n`, 'utf8');
245
- }
246
-
247
- function mailMigrationRecord(auth) {
248
- const markerPath = `${gmailAccountStatePath()}.migrated`;
249
- let subject = '';
250
- try { subject = JSON.parse(Buffer.from(auth.token.split('.')[1], 'base64url').toString()).sub || ''; } catch {}
251
- const owner = auth.userId || (auth.email !== 'unknown' ? auth.email.toLowerCase() : subject || auth.token);
252
- const identity = createHash('sha256').update(owner).digest('hex');
253
- let migrated = {};
254
- try { migrated = JSON.parse(fs.readFileSync(markerPath, 'utf8')); } catch {}
255
- if (!migrated || typeof migrated !== 'object' || Array.isArray(migrated)) migrated = {};
256
- return { markerPath, identity, migrated };
257
- }
258
-
259
- function markMailMigration(record) {
260
- record.migrated[record.identity] = true;
261
- fs.mkdirSync(path.dirname(record.markerPath), { recursive: true });
262
- fs.writeFileSync(record.markerPath, `${JSON.stringify(record.migrated)}\n`, { mode: 0o600 });
263
- }
264
-
265
- async function googleAccountsCapability(token, { refresh = false } = {}) {
266
- const context = googleContext.getStore();
267
- if (!refresh && context?.googleCapability) return context.googleCapability;
268
- const result = await apiRequestJson('/integrations/google/accounts', { method: 'GET', token });
269
- if (!result.ok && result.status !== 404) {
270
- const error = new Error(`could not check google accounts: ${result.error || result.data?.detail || 'request failed'}`);
271
- error.status = result.status || 0;
272
- error.result = result;
273
- throw error;
274
- }
275
- if (result.ok && !Array.isArray(result.data?.accounts)) {
276
- const error = new Error('could not check google accounts: the server returned an incomplete account list');
277
- error.status = result.status || 0;
278
- error.result = result;
279
- throw error;
280
- }
281
- const capability = { supported: result.ok, data: result.data || {}, status: result.status || 0 };
282
- if (context) context.googleCapability = capability;
283
- return capability;
284
- }
285
-
286
- async function prepareMailDefaults(context, { migrate = true } = {}) {
287
- const auth = await getAuth();
288
- const capability = await googleAccountsCapability(auth.token);
289
- if (!capability.supported) {
290
- const sticky = readGmailStickyAccount();
291
- if (sticky && sticky.includes('@')) {
292
- const account = resolveAccountSelector(await fetchGmailAccounts(auth.token), sticky);
293
- if (!account) throw new Error('saved gmail account is not connected');
294
- context.resolvedMail = gmailAccountIdentity(account).id;
295
- }
296
- return;
297
- }
298
- let data = capability.data;
299
- if (!data || !Array.isArray(data.accounts) || !data.defaults || !Object.hasOwn(data.defaults, 'gmail') || !data.resolved || !Object.hasOwn(data.resolved, 'gmail')) {
300
- throw new Error('could not resolve mail account: the server returned an incomplete account list');
301
- }
302
- const record = mailMigrationRecord(auth);
303
- if (migrate && !record.migrated[record.identity]) {
304
- const sticky = readGmailStickyAccount();
305
- const legacy = sticky && resolveAccountSelector(data.accounts, sticky);
306
- const eligible = legacy && legacy.products?.gmail?.connected;
307
- if (sticky && data.defaults.gmail == null && !eligible) console.error('saved mail account is no longer connected; using the server choice.');
308
- if (sticky && data.defaults.gmail == null && eligible) {
309
- const canonical = gmailAccountIdentity(legacy).id;
310
- const saved = await rawApiRequestJson('/integrations/google/defaults', {
311
- method: 'PUT', token: auth.token, body: { account_id: canonical, products: ['gmail'] },
312
- });
313
- if (!saved.ok) return googleFailure(saved, 'move the saved mail default');
314
- const refreshed = await googleAccountsCapability(auth.token, { refresh: true });
315
- if (!refreshed.supported) throw new Error('could not refresh mail account');
316
- data = refreshed.data;
317
- if (!data || !data.resolved || !Object.hasOwn(data.resolved, 'gmail')) throw new Error('could not refresh mail account');
318
- }
319
- markMailMigration(record);
320
- }
321
- context.serverDefaults = true;
322
- context.resolvedMail = data.resolved.gmail || null;
323
- }
324
-
325
- function resolveGmailAccountId(explicitId) {
326
- const trimmed = String(explicitId || '').trim();
327
- if (trimmed) return trimmed;
328
- const context = googleContext.getStore();
329
- if (context?.resolvedMail) return context.resolvedMail;
330
- const sticky = readGmailStickyAccount();
331
- if (sticky) return sticky;
332
- return 'default';
333
- }
334
-
335
- function gmailAccountIdentity(account = {}) {
336
- const id = String(account.account_id || account.id || 'default').trim() || 'default';
337
- const email = String(account.email_address || account.email || '').trim().toLowerCase();
338
- const name = String(account.display_name || account.name || id).trim().toLowerCase() || id.toLowerCase();
339
- return { id, email, name };
340
- }
341
-
342
- function sortGmailAccounts(accounts) {
343
- return [...accounts].sort((left, right) => {
344
- const leftIdentity = gmailAccountIdentity(left);
345
- const rightIdentity = gmailAccountIdentity(right);
346
- const leftDefault = leftIdentity.id.toLowerCase() === 'default';
347
- const rightDefault = rightIdentity.id.toLowerCase() === 'default';
348
- if (leftDefault !== rightDefault) return leftDefault ? -1 : 1;
349
- return leftIdentity.name.localeCompare(rightIdentity.name)
350
- || leftIdentity.id.localeCompare(rightIdentity.id);
351
- });
352
- }
353
-
354
- function formatGmailAccountRows(accounts, activeId = null) {
355
- const rows = sortGmailAccounts(accounts).map((account) => {
356
- const identity = gmailAccountIdentity(account);
357
- return { ...identity, emailLabel: identity.email || 'email pending' };
358
- });
359
- const nameWidth = Math.max(...rows.map((row) => row.name.length));
360
- const emailWidth = Math.max(...rows.map((row) => row.emailLabel.length));
361
- const active = String(activeId || '').toLowerCase();
362
- return rows.map((row) => {
363
- const marker = row.id.toLowerCase() === active ? ' (active)' : '';
364
- return `${row.name.padEnd(nameWidth)} ${row.emailLabel.padEnd(emailWidth)}${marker}`.trimEnd();
365
- });
366
- }
367
-
368
- async function fetchGmailAccounts(token, options = {}) {
369
- const result = await apiRequestJson('/integrations/gmail/accounts', {
370
- method: 'GET',
371
- token,
372
- });
373
- if (!result.ok) {
374
- if (options.quiet) return [];
375
- console.error(`Error: ${result.error || 'Failed to fetch accounts'}`);
376
- process.exit(1);
377
- }
378
- const accounts = result.data?.accounts || result.data || [];
379
- return Array.isArray(accounts) ? accounts : [];
380
- }
381
-
382
- function resolveAccountSelector(accounts, selector, { displayName = false } = {}) {
383
- const wanted = String(selector || '').trim().toLowerCase();
384
- if (!wanted) return null;
385
- const exact = accounts.filter((account) => {
386
- const identity = gmailAccountIdentity(account);
387
- return identity.id.toLowerCase() === wanted || identity.email === wanted;
388
- });
389
- const matches = exact.length || !displayName ? exact : accounts.filter((account) => gmailAccountIdentity(account).name === wanted);
390
- if (matches.length > 1) {
391
- const labels = matches.map((account) => {
392
- const { id, email } = gmailAccountIdentity(account);
393
- return `${id} (${email || 'email pending'})`;
394
- });
395
- throw new Error(`ambiguous account "${selector}": ${labels.join(', ')}. use an account name or email.`);
396
- }
397
- return matches[0] || null;
398
- }
399
-
400
- async function promptForGmailAccount(deps = {}) {
401
- if (deps.prompt) return deps.prompt('choose account: ');
402
- const rl = readline.createInterface({
403
- input: deps.input || process.stdin,
404
- output: deps.output || process.stdout,
405
- });
406
- return new Promise((resolve) => {
407
- rl.question('choose account: ', (answer) => {
408
- rl.close();
409
- resolve(answer);
410
- });
411
- });
412
- }
413
-
414
- async function getAuth() {
415
- const ensured = await ensureValidCredentials(apiRequestJson);
416
- const creds = ensured.error ? null : ensured.credentials;
417
- if (!creds || !creds.token) {
418
- if (ensured.error && ensured.error !== 'not_logged_in') {
419
- console.error(`Authentication failed: ${ensured.detail || ensured.error}. Run: atris login`);
420
- } else {
421
- console.error('Not logged in. Run: atris login');
422
- }
423
- process.exit(1);
424
- }
425
- return { token: creds.token, email: creds.email || 'unknown', userId: creds.user_id || null };
426
- }
427
-
428
- async function getAuthToken() {
429
- return (await getAuth()).token;
430
- }
32
+ const {
33
+ googleContext,
34
+ loadCredentials,
35
+ apiRequestJson,
36
+ takeContext,
37
+ GMAIL_CONNECT_POLL_MS,
38
+ GMAIL_CONNECT_TIMEOUT_MS,
39
+ GMAIL_SEND_USAGE,
40
+ gmailVerdictsPath,
41
+ appendGmailVerdicts,
42
+ readGmailVerdicts,
43
+ printGmailVerdicts,
44
+ parseGmailTriageArgs,
45
+ parseGmailVerdictsArgs,
46
+ gmailAccountStatePath,
47
+ googleAccountsCapability,
48
+ prepareMailDefaults,
49
+ resolveGmailAccountId,
50
+ gmailAccountIdentity,
51
+ sortGmailAccounts,
52
+ formatGmailAccountRows,
53
+ fetchGmailAccounts,
54
+ resolveAccountSelector,
55
+ promptForGmailAccount,
56
+ getAuth,
57
+ getAuthToken,
58
+ extractGmailMailboxAccount,
59
+ parseGmailArgs,
60
+ gmailTriageVerdict,
61
+ parseGmailSendArgs,
62
+ parseGmailVoiceArgs,
63
+ editGmailVoice,
64
+ openGmailAuthUrl,
65
+ googleOldServer,
66
+ googleProductNames,
67
+ googleProducts,
68
+ googleFailure,
69
+ saveGoogleDefault,
70
+ googleSignInRetry,
71
+ googleSignInFailure,
72
+ googleSignInErrorLines,
73
+ localDayBounds,
74
+ localDateBounds,
75
+ compareCalendarEvents,
76
+ calendarReadMetadata,
77
+ formatCalendarEvents,
78
+ parseCalendarSearchArgs,
79
+ filterCalendarEventsForSearch,
80
+ writeCalendarEventsCache,
81
+ readCalendarEventsCache,
82
+ formatCalendarSearchResults,
83
+ formatCalendarSearchError,
84
+ parseLimit,
85
+ argsWithoutLimit,
86
+ resolveSlackChannel,
87
+ formatSlackMessages,
88
+ imessageDoctor,
89
+ printImessageDoctor,
90
+ imessageRecent,
91
+ imessageLookup,
92
+ imessageSend,
93
+ } = require('../lib/integrations-core');
431
94
 
432
95
  // ============================================================================
433
96
  // GMAIL
434
97
  // ============================================================================
435
98
 
436
- function extractGmailMailboxAccount(args = []) {
437
- const out = [];
438
- let mailboxAccountId = null;
439
- for (let i = 0; i < args.length; i += 1) {
440
- const arg = args[i];
441
- if (arg === '--account' && args[i + 1] && !String(args[i + 1]).startsWith('-')) {
442
- mailboxAccountId = args[i + 1];
443
- i += 1;
444
- continue;
445
- }
446
- out.push(arg);
447
- }
448
- return { args: out, mailboxAccountId };
449
- }
450
-
451
- function parseGmailArgs(args = []) {
452
- const options = { accountId: null };
453
- const positional = [];
454
- for (let i = 0; i < args.length; i += 1) {
455
- const arg = args[i];
456
- if (arg === '--account') {
457
- options.accountId = String(args[i + 1] || '').trim();
458
- if (!options.accountId) {
459
- console.error('Usage: --account requires an account id');
460
- process.exit(1);
461
- }
462
- i += 1;
463
- } else {
464
- positional.push(arg);
465
- }
466
- }
467
- return { ...options, positional };
468
- }
469
-
470
99
  async function gmailInbox(options = {}) {
471
100
  const { token, email } = await getAuth();
472
101
  const limit = options.limit || 10;
@@ -480,9 +109,9 @@ async function gmailInbox(options = {}) {
480
109
  console.log('📬 Fetching inbox...\n');
481
110
  }
482
111
 
483
- const path = `/integrations/gmail/messages?max_results=${limit}&account_id=${encodeURIComponent(accountId)}`;
112
+ const reqPath = `/integrations/gmail/messages?max_results=${limit}&account_id=${encodeURIComponent(accountId)}`;
484
113
 
485
- const result = await apiRequestJson(path, {
114
+ const result = await apiRequestJson(reqPath, {
486
115
  method: 'GET',
487
116
  token,
488
117
  });
@@ -527,48 +156,6 @@ async function gmailInbox(options = {}) {
527
156
  return messages;
528
157
  }
529
158
 
530
- function gmailMessageHeaderText(message = {}) {
531
- const lines = [];
532
- const headers = message.headers;
533
- if (Array.isArray(headers)) {
534
- for (const header of headers) {
535
- lines.push(`${String(header?.name || '')}: ${String(header?.value || '')}`);
536
- }
537
- } else if (headers && typeof headers === 'object') {
538
- for (const [name, value] of Object.entries(headers)) lines.push(`${name}: ${String(value || '')}`);
539
- } else if (headers) {
540
- lines.push(String(headers));
541
- }
542
-
543
- for (const name of ['list_id', 'list_unsubscribe', 'mailing_list', 'unsubscribe']) {
544
- if (message[name]) lines.push(`${name}: ${String(message[name])}`);
545
- }
546
- return lines.join('\n').toLowerCase();
547
- }
548
-
549
- function gmailTriageVerdict(message = {}, options = {}) {
550
- const from = String(message.from || message.sender || '').toLowerCase();
551
- let result;
552
- if (/(?:^|[^a-z0-9])(?:no[-_.]?reply|do[-_.]?not[-_.]?reply)(?:[^a-z0-9]|$)/i.test(from)) {
553
- result = { verdict: 'archive', reason: 'noreply sender' };
554
- }
555
-
556
- const domain = from.match(/@([a-z0-9.-]+)/i)?.[1] || '';
557
- if (!result && GMAIL_BULK_DOMAINS.some((bulkDomain) => domain === bulkDomain || domain.endsWith(`.${bulkDomain}`))) {
558
- result = { verdict: 'archive', reason: 'bulk mail domain' };
559
- }
560
-
561
- const headers = gmailMessageHeaderText(message);
562
- if (!result && (/(?:^|\n)(?:list-id|list-unsubscribe|mailing-list|x-campaign-id|x-mailing-list)\s*:/i.test(headers)
563
- || /(?:^|\n)precedence\s*:\s*(?:bulk|junk|list)/i.test(headers)
564
- || /\bunsubscribe\b/i.test(headers))) {
565
- result = { verdict: 'archive', reason: 'list or unsubscribe headers' };
566
- }
567
-
568
- if (!result) result = { verdict: 'keep', reason: 'personal sender' };
569
- return options.details ? result : result.verdict;
570
- }
571
-
572
159
  async function gmailTriage(options = {}) {
573
160
  const accountId = resolveGmailAccountId(options.accountId);
574
161
  const limit = Number.isInteger(options.limit) && options.limit > 0 ? options.limit : 25;
@@ -614,9 +201,9 @@ async function gmailRead(messageId, options = {}) {
614
201
 
615
202
  console.log('📧 Fetching message...\n');
616
203
 
617
- const path = `/integrations/gmail/messages/${messageId}?account_id=${encodeURIComponent(accountId)}`;
204
+ const reqPath = `/integrations/gmail/messages/${messageId}?account_id=${encodeURIComponent(accountId)}`;
618
205
 
619
- const result = await apiRequestJson(path, {
206
+ const result = await apiRequestJson(reqPath, {
620
207
  method: 'GET',
621
208
  token,
622
209
  });
@@ -685,53 +272,6 @@ async function gmailArchive(messageIds, options = {}) {
685
272
  console.log(`Archived ${archived} message${archived === 1 ? '' : 's'}. They stay searchable in All Mail.`);
686
273
  }
687
274
 
688
- function parseGmailSendArgs(args = []) {
689
- let accountId = null;
690
- let bodyFile = null;
691
- let approved = false;
692
- const positional = [];
693
-
694
- for (let i = 0; i < args.length; i += 1) {
695
- const arg = args[i];
696
- if (arg === '--approved' || arg === '--confirm-approved') {
697
- approved = true;
698
- continue;
699
- }
700
- if (arg === '--account' || arg === '--body-file') {
701
- const value = String(args[i + 1] || '').trim();
702
- if (!value || value.startsWith('--')) {
703
- console.error(GMAIL_SEND_USAGE);
704
- process.exit(2);
705
- }
706
- if (arg === '--account') accountId = value;
707
- else bodyFile = value;
708
- i += 1;
709
- continue;
710
- }
711
- positional.push(arg);
712
- }
713
-
714
- const [to, subject, ...bodyParts] = positional;
715
- if (!String(to || '').trim()
716
- || !String(subject || '').trim()
717
- || (bodyFile ? bodyParts.length > 0 : bodyParts.length === 0)) {
718
- console.error(GMAIL_SEND_USAGE);
719
- process.exit(2);
720
- }
721
-
722
- let body = bodyParts.join(' ');
723
- if (bodyFile) {
724
- try {
725
- body = fs.readFileSync(bodyFile, 'utf8');
726
- } catch (error) {
727
- console.error(`could not read body file "${bodyFile}": ${error.message}`);
728
- process.exit(1);
729
- }
730
- }
731
-
732
- return { to, subject, body, accountId, approved };
733
- }
734
-
735
275
  async function gmailSend(to, subject, body, options = {}) {
736
276
  // Approval is checked before any token lookup or network call so an
737
277
  // unapproved send can never reach the server.
@@ -765,51 +305,6 @@ async function gmailSend(to, subject, body, options = {}) {
765
305
  console.log(`sent to ${to} from ${email || id}`);
766
306
  }
767
307
 
768
- function parseGmailVoiceArgs(args = []) {
769
- let clear = false;
770
- const positional = [];
771
- for (const arg of args) {
772
- if (arg === '--clear') clear = true;
773
- else positional.push(arg);
774
- }
775
- if (positional.length > 1 || positional.some((arg) => String(arg).startsWith('--'))) {
776
- console.error(GMAIL_VOICE_USAGE);
777
- process.exit(2);
778
- }
779
- return { accountId: positional[0] || null, clear };
780
- }
781
-
782
- function editGmailVoice(currentVoice, deps = {}) {
783
- const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'atris-gmail-voice-'));
784
- const voicePath = path.join(tempDir, 'voice.md');
785
- let editedVoice;
786
- let editorError = null;
787
-
788
- try {
789
- fs.writeFileSync(voicePath, currentVoice, 'utf8');
790
- const editor = deps.editor || process.env.EDITOR || 'vi';
791
- const shell = deps.shell || process.env.SHELL || '/bin/sh';
792
- const run = deps.spawnSync || spawnSync;
793
- const result = run(shell, ['-c', 'exec $EDITOR "$1"', 'atris-gmail-voice', voicePath], {
794
- stdio: 'inherit',
795
- env: { ...process.env, EDITOR: editor },
796
- });
797
- if (result.error || result.status !== 0) {
798
- editorError = result.error?.message || `editor exited with status ${result.status}`;
799
- } else {
800
- editedVoice = fs.readFileSync(voicePath, 'utf8');
801
- }
802
- } finally {
803
- fs.rmSync(tempDir, { recursive: true, force: true });
804
- }
805
-
806
- if (editorError) {
807
- console.error(`could not edit gmail voice: ${editorError}`);
808
- process.exit(1);
809
- }
810
- return editedVoice;
811
- }
812
-
813
308
  async function gmailVoice(accountId, options = {}) {
814
309
  const token = await getAuthToken();
815
310
  const resolvedAccountId = resolveGmailAccountId(accountId);
@@ -846,14 +341,6 @@ async function gmailVoice(accountId, options = {}) {
846
341
  console.log(`voice ${options.clear ? 'cleared' : 'saved'} for ${name}`);
847
342
  }
848
343
 
849
- function openGmailAuthUrl(authUrl, deps = {}) {
850
- const run = deps.spawnSync || spawnSync;
851
- const platform = deps.platform || process.platform;
852
- const command = platform === 'darwin' ? 'open' : 'xdg-open';
853
- const opened = run(command, [authUrl], { stdio: 'ignore' });
854
- return !opened.error && opened.status === 0;
855
- }
856
-
857
344
  async function gmailConnect(accountName, deps = {}) {
858
345
  const accountId = String(accountName || 'default').trim();
859
346
  if (!/^[a-z0-9-]{1,32}$/.test(accountId)) {
@@ -956,92 +443,6 @@ async function gmailUse(accountId, deps = {}) {
956
443
  console.log(`now using ${name} (${email || 'email pending'})`);
957
444
  }
958
445
 
959
- const googleOldServer = 'calendar and drive accounts need the newest atris server.';
960
- const googleProductNames = ['gmail', 'calendar', 'drive'];
961
-
962
- function googleProducts(value) {
963
- if (value == null) return null;
964
- const products = String(value).split(',').map((part) => part.trim().toLowerCase() === 'mail' ? 'gmail' : part.trim().toLowerCase());
965
- if (!products.length || products.some((part) => !googleProductNames.includes(part)) || new Set(products).size !== products.length) throw new Error('--only needs mail, calendar, or drive');
966
- return products;
967
- }
968
-
969
- function googleCache(defaults, business) {
970
- const file = path.join(stateHome(), '.atris', 'google-accounts.json');
971
- let cache = {};
972
- try { cache = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {}
973
- if (business) cache.businesses = { ...(cache.businesses || {}), [business]: defaults };
974
- else cache.defaults = { ...(cache.defaults || {}), ...defaults };
975
- fs.mkdirSync(path.dirname(file), { recursive: true });
976
- fs.writeFileSync(file, `${JSON.stringify(cache, null, 2)}\n`, 'utf8');
977
- }
978
-
979
- function googleFailure(result, action) {
980
- console.error(`could not ${action}: ${result.error || result.data?.detail || 'request failed'}`);
981
- process.exit(1);
982
- }
983
-
984
- async function saveGoogleDefault(auth, selector, products, business, { mailAccounts, warnLegacy = false } = {}) {
985
- const capability = await googleAccountsCapability(auth.token);
986
- if (!capability.supported && !products.includes('gmail')) {
987
- if (warnLegacy) console.log(googleOldServer);
988
- return { id: String(selector), legacy: true };
989
- }
990
- const roster = capability.supported ? (Array.isArray(capability.data.accounts) ? capability.data.accounts : []) : (mailAccounts || await fetchGmailAccounts(auth.token));
991
- const match = resolveAccountSelector(roster, selector, { displayName: !capability.supported });
992
- if (!match && (String(selector).includes('@') || !capability.supported)) throw new Error('unknown google account');
993
- const id = match ? gmailAccountIdentity(match).id : String(selector);
994
- if (!capability.supported) {
995
- if (warnLegacy) console.log(googleOldServer);
996
- if (products.includes('gmail')) writeGmailStickyAccount(id);
997
- return { id, legacy: true };
998
- }
999
- const url = business ? `/business/${encodeURIComponent(business)}/integrations/google` : '/integrations/google/defaults';
1000
- const body = business ? { account_id: id } : { account_id: id, products };
1001
- const options = { method: 'PUT', token: auth.token, body };
1002
- if (business) options.headers = { 'X-Business-Id': business };
1003
- const saved = await rawApiRequestJson(url, options);
1004
- if (!saved.ok) return googleFailure(saved, 'save google default');
1005
- googleCache(Object.fromEntries((business ? googleProductNames : products).map((product) => [product, id])), business);
1006
- if (products.includes('gmail') && !business) {
1007
- markMailMigration(mailMigrationRecord(auth));
1008
- writeGmailStickyAccount(id);
1009
- }
1010
- return { id, legacy: false };
1011
- }
1012
-
1013
- function googleSignInRetry(name, products, business) {
1014
- return ['atris google connect', name, products && `--only ${products.map((p) => p === 'gmail' ? 'mail' : p).join(',')}`, business ? `--business ${business}` : '--account'].filter(Boolean).join(' ');
1015
- }
1016
-
1017
- function googleSignInFailure(result, { error, retry, status = 'error', extra = {} } = {}) {
1018
- const data = result?.data && typeof result.data === 'object' && !Array.isArray(result.data) ? result.data : {};
1019
- 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));
1020
- return 1;
1021
- }
1022
-
1023
- function googleSignInErrorLines(status, business) {
1024
- const reason = String(status.error || 'unknown error').replace(/_/g, ' ').toLowerCase();
1025
- const lines = [status.error === 'superseded'
1026
- ? 'the google connection changed before sign-in finished.'
1027
- : `google sign-in did not finish: ${reason}.`];
1028
- if (status.account_id) lines.push(`account: ${status.account_id}.`);
1029
- if (Array.isArray(status.products)) {
1030
- const products = status.products.map((p) => p === 'gmail' ? 'mail' : p);
1031
- lines.push(products.length ? `products still connected: ${products.join(', ')}.` : 'no products remain connected.');
1032
- } else lines.push('which products remain connected is unknown.');
1033
- if (Array.isArray(status.failed) && status.failed.length) {
1034
- const failed = status.failed.map((p) => p === 'gmail' ? 'mail' : p === 'result' ? 'saving the sign-in result' : p);
1035
- lines.push(`could not finish: ${failed.join(', ')}.`);
1036
- }
1037
- if (Array.isArray(status.missing) && status.missing.length) lines.push(`google did not grant: ${status.missing.map((p) => p === 'gmail' ? 'mail' : p).join(', ')}.`);
1038
- if (status.cleanup_incomplete) lines.push('some changes could not be undone.');
1039
- if (status.business_assignment_unknown) lines.push('the business account choice may not have been saved.');
1040
- const check = business ? `atris google accounts --business ${business}` : 'atris google accounts --account';
1041
- lines.push(`check ${check} before trying again.`);
1042
- return lines;
1043
- }
1044
-
1045
446
  async function googleConnect(name, products, business, deps = {}) {
1046
447
  if (name && (!/^[a-z0-9-]{1,32}$/.test(name) || name === 'all')) throw new Error('invalid google account name. use 1 to 32 lowercase letters, numbers, or hyphens');
1047
448
  const retry = googleSignInRetry(name, products, business);
@@ -1322,275 +723,6 @@ async function gmailCommandInner(subcommand, ...args) {
1322
723
  // CALENDAR
1323
724
  // ============================================================================
1324
725
 
1325
- function localDayBounds(offsetDays = 0) {
1326
- const start = new Date();
1327
- start.setHours(0, 0, 0, 0);
1328
- start.setDate(start.getDate() + offsetDays);
1329
- const end = new Date(start);
1330
- end.setDate(end.getDate() + 1);
1331
- return { start, end };
1332
- }
1333
-
1334
- function localDateBounds(dateText) {
1335
- if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dateText || ''))) {
1336
- console.error('Usage: atris calendar date YYYY-MM-DD');
1337
- process.exit(1);
1338
- }
1339
- const start = new Date(`${dateText}T00:00:00`);
1340
- if (Number.isNaN(start.getTime())) {
1341
- console.error('Invalid date. Use YYYY-MM-DD.');
1342
- process.exit(1);
1343
- }
1344
- const end = new Date(start);
1345
- end.setDate(end.getDate() + 1);
1346
- return { start, end };
1347
- }
1348
-
1349
- function calendarEventTimeValue(value) {
1350
- if (!value) return '';
1351
- if (typeof value === 'string') return value;
1352
- return value.dateTime || value.date || '';
1353
- }
1354
-
1355
- function isDateOnly(value) {
1356
- return /^\d{4}-\d{2}-\d{2}$/.test(String(value || ''));
1357
- }
1358
-
1359
- // All-day dates start at local midnight; invalid or missing dates sort last.
1360
- function compareCalendarEvents(left, right) {
1361
- const instant = (event) => {
1362
- const value = calendarEventTimeValue(event.start || event.start_time || event.startTime);
1363
- if (!value) return NaN;
1364
- if (isDateOnly(value)) {
1365
- const parsed = new Date(`${value}T00:00:00`);
1366
- if (Number.isNaN(parsed.getTime()) || `${parsed.getFullYear()}-${String(parsed.getMonth() + 1).padStart(2, '0')}-${String(parsed.getDate()).padStart(2, '0')}` !== value) return NaN;
1367
- return parsed.getTime();
1368
- }
1369
- return Date.parse(value);
1370
- };
1371
- const a = instant(left), b = instant(right);
1372
- if (!Number.isFinite(a)) return Number.isFinite(b) ? 1 : 0;
1373
- if (!Number.isFinite(b)) return -1;
1374
- return a - b;
1375
- }
1376
-
1377
- function calendarReadMetadata(data) {
1378
- const accounts = Array.isArray(data?.accounts) ? data.accounts : [];
1379
- const errors = Array.isArray(data?.errors) ? data.errors : [];
1380
- return { accounts, errors, partial: errors.length > 0 };
1381
- }
1382
-
1383
- function formatCalendarTimeRange(event) {
1384
- const start = calendarEventTimeValue(event.start || event.start_time || event.startTime);
1385
- const end = calendarEventTimeValue(event.end || event.end_time || event.endTime);
1386
- if (!start || isDateOnly(start)) return 'All day';
1387
- const startDate = new Date(start);
1388
- if (Number.isNaN(startDate.getTime())) return 'Time unavailable';
1389
- const startText = startDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
1390
- if (!end || isDateOnly(end)) return startText;
1391
- const endDate = new Date(end);
1392
- if (Number.isNaN(endDate.getTime())) return startText;
1393
- return `${startText}-${endDate.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' })}`;
1394
- }
1395
-
1396
- function formatCalendarEvents(label, events) {
1397
- if (events.length === 0) {
1398
- // label is already a phrase like "today's events" or "events on 2026-08-24"
1399
- console.log(`No ${label}.`);
1400
- return;
1401
- }
1402
-
1403
- console.log('─'.repeat(50));
1404
-
1405
- const multipleAccounts = new Set(events.map((event) => event.account_id).filter(Boolean)).size > 1;
1406
- for (const event of events) {
1407
- const time = formatCalendarTimeRange(event);
1408
- const title = event.summary || '(no title)';
1409
-
1410
- const account = multipleAccounts ? ` (${event.account_email || event.account_id || 'unknown'})` : '';
1411
- console.log(`${time} ${title}${account}`);
1412
- if (event.location) {
1413
- console.log(` 📍 ${event.location}`);
1414
- }
1415
- }
1416
-
1417
- console.log('─'.repeat(50));
1418
- }
1419
-
1420
- function parseCalendarSearchArgs(args = []) {
1421
- const values = [];
1422
- const options = { days: 30, limit: 20, timeoutMs: 10000, json: false };
1423
- for (let i = 0; i < args.length; i += 1) {
1424
- const arg = args[i];
1425
- if (arg === '--json') {
1426
- options.json = true;
1427
- } else if (arg === '--days' || arg === '-d') {
1428
- const parsed = Number.parseInt(args[i + 1], 10);
1429
- if (Number.isFinite(parsed) && parsed > 0) options.days = Math.min(parsed, 365);
1430
- i += 1;
1431
- } else if (arg === '--limit' || arg === '-n') {
1432
- const parsed = Number.parseInt(args[i + 1], 10);
1433
- if (Number.isFinite(parsed) && parsed > 0) options.limit = Math.min(parsed, 100);
1434
- i += 1;
1435
- } else if (arg === '--timeout-ms' || arg === '--timeout') {
1436
- const parsed = Number.parseInt(args[i + 1], 10);
1437
- if (Number.isFinite(parsed) && parsed > 0) options.timeoutMs = Math.min(parsed, 60000);
1438
- i += 1;
1439
- } else {
1440
- values.push(arg);
1441
- }
1442
- }
1443
- return { query: values.join(' ').trim(), ...options };
1444
- }
1445
-
1446
- function calendarAttendeesText(attendees) {
1447
- if (!Array.isArray(attendees)) return '';
1448
- return attendees
1449
- .map((attendee) => {
1450
- if (typeof attendee === 'string') return attendee;
1451
- return [
1452
- attendee.email,
1453
- attendee.displayName,
1454
- attendee.name,
1455
- attendee.responseStatus,
1456
- ].filter(Boolean).join(' ');
1457
- })
1458
- .filter(Boolean)
1459
- .join(' ');
1460
- }
1461
-
1462
- function calendarSearchText(event = {}) {
1463
- return [
1464
- event.summary,
1465
- event.title,
1466
- event.location,
1467
- event.description,
1468
- calendarAttendeesText(event.attendees),
1469
- ].filter(Boolean).join(' ').toLowerCase();
1470
- }
1471
-
1472
- function filterCalendarEventsForSearch(events = [], query = '', limit = 20) {
1473
- const terms = String(query || '').toLowerCase().split(/\s+/).filter(Boolean);
1474
- if (!terms.length) return [];
1475
- return events
1476
- .filter((event) => {
1477
- const haystack = calendarSearchText(event);
1478
- return terms.every((term) => haystack.includes(term));
1479
- })
1480
- .slice(0, limit);
1481
- }
1482
-
1483
- function compactCalendarEvent(event = {}) {
1484
- return {
1485
- id: event.id || event.event_id || '',
1486
- start: calendarEventTimeValue(event.start || event.start_time || event.startTime),
1487
- end: calendarEventTimeValue(event.end || event.end_time || event.endTime),
1488
- summary: event.summary || event.title || '(no title)',
1489
- location: event.location || '',
1490
- htmlLink: event.htmlLink || event.link || '',
1491
- ...(event.account_id ? { account_id: event.account_id } : {}),
1492
- ...(event.account_email ? { account_email: event.account_email } : {}),
1493
- };
1494
- }
1495
-
1496
- function calendarCachePacket(events = [], meta = {}) {
1497
- return {
1498
- ok: true,
1499
- updatedAt: new Date().toISOString(),
1500
- source: meta.source || 'live',
1501
- days: meta.days || null,
1502
- count: events.length,
1503
- accounts: meta.accounts || [],
1504
- errors: meta.errors || [],
1505
- partial: Boolean(meta.partial),
1506
- events: events.map(compactCalendarEvent),
1507
- };
1508
- }
1509
-
1510
- function writeCalendarEventsCache(events = [], meta = {}) {
1511
- const rows = Array.isArray(events) ? events : [];
1512
- try {
1513
- fs.mkdirSync(path.dirname(CALENDAR_CACHE_PATH), { recursive: true });
1514
- fs.writeFileSync(CALENDAR_CACHE_PATH, `${JSON.stringify(calendarCachePacket(rows, meta), null, 2)}\n`, 'utf8');
1515
- return CALENDAR_CACHE_PATH;
1516
- } catch {
1517
- return '';
1518
- }
1519
- }
1520
-
1521
- function readCalendarEventsCache() {
1522
- try {
1523
- const packet = JSON.parse(fs.readFileSync(CALENDAR_CACHE_PATH, 'utf8'));
1524
- const events = Array.isArray(packet.events) ? packet.events : [];
1525
- return {
1526
- ok: true,
1527
- path: CALENDAR_CACHE_PATH,
1528
- updatedAt: packet.updatedAt || '',
1529
- accounts: Array.isArray(packet.accounts) ? packet.accounts : [],
1530
- errors: Array.isArray(packet.errors) ? packet.errors : [],
1531
- partial: Boolean(packet.partial || packet.errors?.length),
1532
- events,
1533
- };
1534
- } catch {
1535
- return { ok: false, path: CALENDAR_CACHE_PATH, updatedAt: '', events: [] };
1536
- }
1537
- }
1538
-
1539
- function formatCalendarSearchResults(query, matches, {
1540
- json = false,
1541
- total = matches.length,
1542
- source = 'live',
1543
- liveOk = true,
1544
- cacheUpdatedAt = '',
1545
- error = '',
1546
- accounts = [],
1547
- errors = [],
1548
- partial = false,
1549
- } = {}) {
1550
- if (json) {
1551
- console.log(JSON.stringify({
1552
- ok: true,
1553
- query,
1554
- source,
1555
- liveOk,
1556
- cacheUpdatedAt,
1557
- error,
1558
- accounts,
1559
- errors,
1560
- partial: Boolean(partial || errors.length),
1561
- totalEvents: total,
1562
- matchCount: matches.length,
1563
- matches: matches.map(compactCalendarEvent),
1564
- }, null, 2));
1565
- return;
1566
- }
1567
-
1568
- if (!matches.length) {
1569
- console.log(`No upcoming events matched "${query}".`);
1570
- return;
1571
- }
1572
-
1573
- console.log(`Found ${matches.length} upcoming event(s) matching "${query}":\n`);
1574
- formatCalendarEvents('matching your search', matches);
1575
- }
1576
-
1577
- function formatCalendarSearchError(query, message, { json = false, status = 0 } = {}) {
1578
- if (json) {
1579
- console.log(JSON.stringify({
1580
- ok: false,
1581
- query,
1582
- status,
1583
- error: message || 'Failed to fetch events',
1584
- totalEvents: 0,
1585
- matchCount: 0,
1586
- matches: [],
1587
- }, null, 2));
1588
- return;
1589
- }
1590
- console.error(`Error: ${message || 'Failed to fetch events'}`);
1591
- process.exit(1);
1592
- }
1593
-
1594
726
  async function calendarRange(label, { timeMin, timeMax, days } = {}) {
1595
727
  const { token, email } = await getAuth();
1596
728
 
@@ -1807,7 +939,7 @@ async function calendarCommandInner(subcommand, ...args) {
1807
939
  console.log(' atris calendar week - Show this week\'s events');
1808
940
  console.log(' atris calendar date YYYY-MM-DD - Show events on a date');
1809
941
  console.log(' atris calendar search <query> [--days 30] [--limit 20] [--timeout-ms 10000] [--json] - Search upcoming events');
1810
- console.log(' atris calendar add \"Title\" --date YYYY-MM-DD --from HH:MM --to HH:MM - Create an event');
942
+ console.log(' atris calendar add "Title" --date YYYY-MM-DD --from HH:MM --to HH:MM - Create an event');
1811
943
  }
1812
944
  }
1813
945
 
@@ -1954,59 +1086,6 @@ async function slackDms() {
1954
1086
  }
1955
1087
  }
1956
1088
 
1957
- function parseLimit(args, fallback = 20) {
1958
- const idx = args.findIndex((arg) => arg === '--limit' || arg === '-n');
1959
- const raw = idx >= 0 ? args[idx + 1] : '';
1960
- const parsed = Number.parseInt(raw, 10);
1961
- return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, 100) : fallback;
1962
- }
1963
-
1964
- function argsWithoutLimit(args = []) {
1965
- const out = [];
1966
- for (let i = 0; i < args.length; i += 1) {
1967
- if (args[i] === '--limit' || args[i] === '-n') {
1968
- i += 1;
1969
- continue;
1970
- }
1971
- out.push(args[i]);
1972
- }
1973
- return out;
1974
- }
1975
-
1976
- async function slackPersonalChannels(token) {
1977
- const result = await apiRequestJson('/integrations/slack/me/channels', {
1978
- method: 'GET',
1979
- token,
1980
- });
1981
- if (!result.ok) return [];
1982
- return result.data?.channels || result.data || [];
1983
- }
1984
-
1985
- async function resolveSlackChannel(token, channel) {
1986
- const text = String(channel || '').trim();
1987
- if (!text) return '';
1988
- if (/^[A-Z][A-Z0-9]{5,}$/.test(text)) return text;
1989
- const wanted = text.replace(/^#/, '').toLowerCase();
1990
- const channels = await slackPersonalChannels(token);
1991
- const match = channels.find((ch) => String(ch.name || '').toLowerCase() === wanted || String(ch.id || '').toLowerCase() === wanted);
1992
- return match?.id || text;
1993
- }
1994
-
1995
- function formatSlackMessages(messages) {
1996
- if (!messages.length) {
1997
- console.log('No Slack messages found.');
1998
- return;
1999
- }
2000
- console.log('─'.repeat(60));
2001
- for (const message of messages) {
2002
- const author = message.user_name || message.username || message.user || message.sender || 'unknown';
2003
- const ts = message.datetime || message.time || message.ts || message.created_at || '';
2004
- const text = String(message.text || message.content || message.message || '').replace(/\s+/g, ' ').trim();
2005
- console.log(`${ts} ${author}: ${text || '(no text)'}`);
2006
- }
2007
- console.log('─'.repeat(60));
2008
- }
2009
-
2010
1089
  async function slackMessages(channel, args = []) {
2011
1090
  if (!channel) {
2012
1091
  console.error('Usage: atris slack messages <channel-or-id> [--limit 20]');
@@ -2160,632 +1239,6 @@ async function slackCommand(subcommand, ...args) {
2160
1239
  // IMESSAGE
2161
1240
  // ============================================================================
2162
1241
 
2163
- function imessageDoctor() {
2164
- const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
2165
- const checks = {
2166
- macos: process.platform === 'darwin',
2167
- chat_db_exists: false,
2168
- chat_db_readable: false,
2169
- sqlite3_available: false,
2170
- osascript_available: false,
2171
- messages_automation: false,
2172
- };
2173
- const issues = [];
2174
-
2175
- checks.chat_db_exists = fs.existsSync(chatDb);
2176
- if (checks.chat_db_exists) {
2177
- try {
2178
- fs.accessSync(chatDb, fs.constants.R_OK);
2179
- checks.chat_db_readable = true;
2180
- } catch {
2181
- issues.push('Messages database exists but is not readable. Grant Full Disk Access to this terminal or Atris.');
2182
- }
2183
- } else {
2184
- issues.push('Messages database not found on this Mac.');
2185
- }
2186
-
2187
- checks.sqlite3_available = spawnSync('sqlite3', ['--version'], { encoding: 'utf8' }).status === 0;
2188
- if (!checks.sqlite3_available) issues.push('sqlite3 is not available.');
2189
-
2190
- checks.osascript_available = spawnSync('osascript', ['-e', 'return "ok"'], { encoding: 'utf8' }).status === 0;
2191
- if (!checks.osascript_available) issues.push('osascript is not available.');
2192
- if (checks.osascript_available) {
2193
- checks.messages_automation = spawnSync('osascript', ['-e', 'tell application "Messages" to count services'], {
2194
- encoding: 'utf8',
2195
- timeout: 4000,
2196
- }).status === 0;
2197
- }
2198
- if (!checks.messages_automation) issues.push('Messages automation permission is not available yet.');
2199
-
2200
- if (!checks.macos) issues.push('Local iMessage requires macOS.');
2201
-
2202
- const connected = checks.macos && checks.chat_db_exists && checks.chat_db_readable && checks.sqlite3_available && checks.osascript_available && checks.messages_automation;
2203
- return {
2204
- connected,
2205
- provider: 'local_imessage',
2206
- mode: 'local',
2207
- checks,
2208
- issues,
2209
- next_step: connected
2210
- ? 'iMessage is available on this Mac.'
2211
- : 'Open System Settings -> Privacy & Security -> Full Disk Access and allow your terminal or Atris, then run this check again.',
2212
- };
2213
- }
2214
-
2215
- function printImessageDoctor(result, json = false) {
2216
- if (json) {
2217
- console.log(JSON.stringify(result, null, 2));
2218
- return;
2219
- }
2220
- console.log('iMessage local check\n');
2221
- console.log(`Status: ${result.connected ? 'Connected on this Mac' : 'Needs permission or setup'}`);
2222
- for (const [name, ok] of Object.entries(result.checks)) {
2223
- console.log(` ${ok ? '✓' : '✗'} ${name.replace(/_/g, ' ')}`);
2224
- }
2225
- if (result.issues.length) {
2226
- console.log('\nNext:');
2227
- for (const issue of result.issues) console.log(` - ${issue}`);
2228
- console.log(` - ${result.next_step}`);
2229
- }
2230
- }
2231
-
2232
- function imessageRecent(handle, options = {}) {
2233
- if (!handle) {
2234
- console.error('Usage: atris imessage recent <phone-or-email> [--limit 20]');
2235
- process.exit(1);
2236
- }
2237
- const doctor = imessageDoctor();
2238
- if (!doctor.connected) {
2239
- printImessageDoctor(doctor, Boolean(options.json));
2240
- process.exit(1);
2241
- }
2242
-
2243
- const limit = Number(options.limit || 20);
2244
- const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
2245
- const sql = `
2246
- SELECT datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') AS ts,
2247
- CASE m.is_from_me WHEN 1 THEN 'me' ELSE h.id END AS sender,
2248
- replace(replace(COALESCE(m.text,''), char(10), ' '), char(13), ' ') AS text
2249
- FROM message m
2250
- JOIN handle h ON h.rowid = m.handle_id
2251
- WHERE h.id = '${String(handle).replace(/'/g, "''")}'
2252
- ORDER BY m.date DESC
2253
- LIMIT ${Math.max(1, Math.min(100, limit))};
2254
- `;
2255
- const result = spawnSync('sqlite3', ['-readonly', chatDb, sql], { encoding: 'utf8' });
2256
- if (result.status !== 0) {
2257
- console.error(result.stderr || 'Failed to read Messages database.');
2258
- process.exit(1);
2259
- }
2260
- console.log(result.stdout.trim() || 'No recent messages found.');
2261
- }
2262
-
2263
- function escapeSqlString(value) {
2264
- return String(value || '').replace(/'/g, "''");
2265
- }
2266
-
2267
- function normalizeLookupKey(value) {
2268
- return String(value || '')
2269
- .toLowerCase()
2270
- .replace(/[^a-z0-9@+]+/g, ' ')
2271
- .replace(/\s+/g, ' ')
2272
- .trim();
2273
- }
2274
-
2275
- function normalizeImessageHandle(value) {
2276
- const raw = String(value || '').trim();
2277
- if (!raw) return '';
2278
- if (raw.includes('@')) return raw;
2279
- const digits = raw.replace(/[^\d]/g, '');
2280
- if (digits.length === 10) return `+1${digits}`;
2281
- if (digits.length === 11 && digits.startsWith('1')) return `+${digits}`;
2282
- return raw;
2283
- }
2284
-
2285
- function normalizeContactLabel(value) {
2286
- return String(value || '')
2287
- .replace(/^_\$!<|>!\$_$/g, '')
2288
- .replace(/^\$!<|>!\$$/g, '')
2289
- .trim() || 'other';
2290
- }
2291
-
2292
- function imessageLookupCachePath() {
2293
- return path.join(stateHome(), '.atris', 'cache', 'imessage-contacts.json');
2294
- }
2295
-
2296
- function readImessageLookupCache() {
2297
- const cachePath = imessageLookupCachePath();
2298
- try {
2299
- return JSON.parse(fs.readFileSync(cachePath, 'utf8'));
2300
- } catch {
2301
- return { version: 1, entries: {} };
2302
- }
2303
- }
2304
-
2305
- function writeImessageLookupCache(cache) {
2306
- const cachePath = imessageLookupCachePath();
2307
- fs.mkdirSync(path.dirname(cachePath), { recursive: true });
2308
- fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
2309
- return cachePath;
2310
- }
2311
-
2312
- function readLatestOutgoingImessage(handle, sinceMs) {
2313
- const chatDb = path.join(os.homedir(), 'Library', 'Messages', 'chat.db');
2314
- const sinceUnix = Math.max(0, Math.floor(Number(sinceMs || Date.now()) / 1000) - 5);
2315
- const sql = `
2316
- SELECT m.rowid,
2317
- datetime(m.date/1000000000 + 978307200, 'unixepoch', 'localtime') AS ts,
2318
- m.is_sent,
2319
- m.is_delivered,
2320
- m.is_finished,
2321
- COALESCE(m.error, 0) AS error,
2322
- length(COALESCE(m.text,'')) AS text_len
2323
- FROM message m
2324
- JOIN handle h ON h.rowid = m.handle_id
2325
- WHERE h.id = '${escapeSqlString(handle)}'
2326
- AND m.is_from_me = 1
2327
- AND (m.date/1000000000 + 978307200) >= ${sinceUnix}
2328
- ORDER BY m.date DESC
2329
- LIMIT 1;
2330
- `;
2331
- const result = spawnSync('sqlite3', ['-readonly', chatDb, sql], { encoding: 'utf8' });
2332
- if (result.status !== 0) {
2333
- return {
2334
- matched: false,
2335
- error: (result.stderr || 'Failed to verify latest outgoing iMessage.').trim(),
2336
- };
2337
- }
2338
- const row = String(result.stdout || '').trim();
2339
- if (!row) {
2340
- return {
2341
- matched: false,
2342
- error: 'No outgoing Messages row found after send.',
2343
- };
2344
- }
2345
- const [rowid, ts, isSent, isDelivered, isFinished, messageError, textLen] = row.split('|');
2346
- return {
2347
- matched: true,
2348
- rowid,
2349
- timestamp: ts,
2350
- is_sent: Number(isSent) === 1,
2351
- is_delivered: Number(isDelivered) === 1,
2352
- is_finished: Number(isFinished) === 1,
2353
- message_error: Number(messageError) || 0,
2354
- text_readable: Number(textLen) > 0,
2355
- };
2356
- }
2357
-
2358
- function imessageVerifyLatestOutgoing(handle, sinceMs, options = {}) {
2359
- const timeoutMs = Math.max(0, Number(options.timeoutMs || 2000));
2360
- const intervalMs = Math.max(25, Number(options.intervalMs || 150));
2361
- const startedAt = Date.now();
2362
- let latest = readLatestOutgoingImessage(handle, sinceMs);
2363
- while (
2364
- latest.matched
2365
- && latest.message_error === 0
2366
- && !(latest.is_sent || latest.is_delivered || latest.is_finished)
2367
- && Date.now() - startedAt < timeoutMs
2368
- ) {
2369
- sleepSync(intervalMs);
2370
- latest = readLatestOutgoingImessage(handle, sinceMs);
2371
- }
2372
- return {
2373
- ...latest,
2374
- settled: Boolean(latest.matched && latest.message_error === 0 && (latest.is_sent || latest.is_delivered || latest.is_finished)),
2375
- waited_ms: Date.now() - startedAt,
2376
- };
2377
- }
2378
-
2379
- function parseImessageLookupArgs(args) {
2380
- const options = {
2381
- json: false,
2382
- refresh: false,
2383
- name: '',
2384
- maxAgeMs: 7 * 24 * 60 * 60 * 1000,
2385
- };
2386
- const positional = [];
2387
- for (let i = 0; i < args.length; i += 1) {
2388
- const arg = args[i];
2389
- if (arg === '--json') {
2390
- options.json = true;
2391
- } else if (arg === '--refresh') {
2392
- options.refresh = true;
2393
- } else if (arg === '--name' || arg === '--query') {
2394
- options.name = args[i + 1] || '';
2395
- i += 1;
2396
- } else if (arg === '--max-age-minutes') {
2397
- options.maxAgeMs = Math.max(0, Number(args[i + 1] || 0) * 60 * 1000);
2398
- i += 1;
2399
- } else {
2400
- positional.push(arg);
2401
- }
2402
- }
2403
- if (!options.name && positional.length) options.name = positional.join(' ');
2404
- options.name = String(options.name || '').trim();
2405
- return options;
2406
- }
2407
-
2408
- const CONTACT_LOOKUP_SCRIPT = `
2409
- function run(argv) {
2410
- const query = String(argv[0] || '').trim();
2411
- const Contacts = Application('Contacts');
2412
- const selfAlias = /^(me|myself|self|my number|my phone|my contact)$/i.test(query);
2413
- function safe(fn) {
2414
- try {
2415
- const value = fn();
2416
- return value === null || value === undefined ? '' : String(value);
2417
- } catch (error) {
2418
- return '';
2419
- }
2420
- }
2421
- function propertyRows(rows) {
2422
- const out = [];
2423
- try {
2424
- const list = rows();
2425
- for (let i = 0; i < list.length; i += 1) {
2426
- const item = list[i];
2427
- out.push({
2428
- label: safe(function () { return item.label(); }),
2429
- value: safe(function () { return item.value(); })
2430
- });
2431
- }
2432
- } catch (error) {}
2433
- return out.filter(function (row) { return row.value; });
2434
- }
2435
- function contactRow(person) {
2436
- return {
2437
- id: safe(function () { return person.id(); }),
2438
- name: safe(function () { return person.name(); }),
2439
- first_name: safe(function () { return person.firstName(); }),
2440
- last_name: safe(function () { return person.lastName(); }),
2441
- organization: safe(function () { return person.organization(); }),
2442
- phones: propertyRows(function () { return person.phones(); }),
2443
- emails: propertyRows(function () { return person.emails(); })
2444
- };
2445
- }
2446
- let people = [];
2447
- if (selfAlias) {
2448
- if (Contacts.myCard.exists()) people = [Contacts.myCard];
2449
- } else {
2450
- people = Contacts.people.whose({ name: { _contains: query } })();
2451
- if (!people.length && query.indexOf(' ') > -1) {
2452
- people = Contacts.people.whose({ name: { _contains: query.split(/\\s+/)[0] } })();
2453
- }
2454
- }
2455
- const rows = [];
2456
- const limit = Math.min(20, people.length);
2457
- for (let i = 0; i < limit; i += 1) rows.push(contactRow(people[i]));
2458
- return JSON.stringify({ ok: true, query: query, self_alias: selfAlias, contacts_count: people.length, matches: rows });
2459
- }
2460
- `;
2461
-
2462
- function scoreImessageContact(query, match, selfAlias = false) {
2463
- if (selfAlias) return 100;
2464
- const q = normalizeLookupKey(query);
2465
- const name = normalizeLookupKey(match.name);
2466
- if (!q || !name) return 0;
2467
- if (name === q) return 95;
2468
- if (name.includes(q)) return 80;
2469
- const tokens = q.split(' ').filter(Boolean);
2470
- const nameTokens = new Set(name.split(' ').filter(Boolean));
2471
- if (tokens.length && tokens.every((token) => nameTokens.has(token))) return 75;
2472
- if (tokens.length === 1 && nameTokens.has(tokens[0])) return 65;
2473
- return 40;
2474
- }
2475
-
2476
- function shapeImessageLookupPayload(raw, options, cached = false, cachedAt = null) {
2477
- const query = options.name;
2478
- const selfAlias = Boolean(raw.self_alias);
2479
- const matches = (raw.matches || [])
2480
- .map((match) => {
2481
- const phones = (match.phones || [])
2482
- .map((phone) => ({
2483
- label: normalizeContactLabel(phone.label),
2484
- value: String(phone.value || '').trim(),
2485
- handle: normalizeImessageHandle(phone.value),
2486
- }))
2487
- .filter((phone) => phone.handle);
2488
- const emails = (match.emails || [])
2489
- .map((email) => ({
2490
- label: normalizeContactLabel(email.label),
2491
- value: String(email.value || '').trim(),
2492
- handle: String(email.value || '').trim(),
2493
- }))
2494
- .filter((email) => email.handle);
2495
- const handles = [
2496
- ...phones.map((phone) => ({ type: 'phone', label: phone.label, handle: phone.handle })),
2497
- ...emails.map((email) => ({ type: 'email', label: email.label, handle: email.handle })),
2498
- ];
2499
- return {
2500
- id: match.id || '',
2501
- name: match.name || [match.first_name, match.last_name].filter(Boolean).join(' '),
2502
- phones,
2503
- emails,
2504
- handles,
2505
- primary_handle: handles[0]?.handle || '',
2506
- score: scoreImessageContact(query, match, selfAlias),
2507
- };
2508
- })
2509
- .filter((match) => match.primary_handle)
2510
- .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
2511
- const exactMatches = matches.filter((match) => normalizeLookupKey(match.name) === normalizeLookupKey(query));
2512
- const primary = matches.length === 1
2513
- ? matches[0]
2514
- : exactMatches.length === 1
2515
- ? exactMatches[0]
2516
- : null;
2517
- return {
2518
- ok: true,
2519
- action: 'imessage_lookup',
2520
- provider: 'local_contacts',
2521
- query,
2522
- cached,
2523
- cached_at: cachedAt,
2524
- cache_path: imessageLookupCachePath(),
2525
- match_count: matches.length,
2526
- unique: Boolean(primary),
2527
- ambiguous: matches.length > 1 && !primary,
2528
- primary: primary ? {
2529
- name: primary.name,
2530
- handle: primary.primary_handle,
2531
- handles: primary.handles,
2532
- } : null,
2533
- matches,
2534
- };
2535
- }
2536
-
2537
- function printImessageLookupPayload(payload, json = false) {
2538
- if (json) {
2539
- console.log(JSON.stringify(payload, null, 2));
2540
- return;
2541
- }
2542
- if (!payload.ok) {
2543
- console.error(payload.error || 'Contact lookup failed.');
2544
- return;
2545
- }
2546
- if (!payload.matches.length) {
2547
- console.log(`No iMessage contacts found for "${payload.query}".`);
2548
- return;
2549
- }
2550
- if (payload.primary) {
2551
- console.log(`${payload.primary.name}: ${payload.primary.handle}${payload.cached ? ' (cached)' : ''}`);
2552
- return;
2553
- }
2554
- console.log(`Multiple matches for "${payload.query}":`);
2555
- for (const match of payload.matches.slice(0, 8)) {
2556
- console.log(` - ${match.name}: ${match.primary_handle}`);
2557
- }
2558
- }
2559
-
2560
- function imessageLookup(args = []) {
2561
- const options = parseImessageLookupArgs(args);
2562
- if (!options.name) {
2563
- printImessageLookupPayload({
2564
- ok: false,
2565
- action: 'imessage_lookup',
2566
- error: 'Usage: atris imessage lookup --name <contact-name> [--json] [--refresh]',
2567
- }, options.json);
2568
- process.exit(1);
2569
- }
2570
-
2571
- const key = normalizeLookupKey(options.name);
2572
- const cache = readImessageLookupCache();
2573
- const entry = cache.entries?.[key];
2574
- if (!options.refresh && entry && Date.now() - Number(entry.cached_at || 0) <= options.maxAgeMs) {
2575
- printImessageLookupPayload(shapeImessageLookupPayload(entry.raw, options, true, entry.cached_at), options.json);
2576
- return;
2577
- }
2578
-
2579
- const result = spawnSync('osascript', ['-l', 'JavaScript', '-e', CONTACT_LOOKUP_SCRIPT, options.name], {
2580
- encoding: 'utf8',
2581
- timeout: 6000,
2582
- });
2583
- if (result.status !== 0) {
2584
- printImessageLookupPayload({
2585
- ok: false,
2586
- action: 'imessage_lookup',
2587
- query: options.name,
2588
- error: (result.stderr || 'Contacts lookup failed. Grant Contacts automation permission to this terminal or Atris.').trim(),
2589
- }, options.json);
2590
- process.exit(1);
2591
- }
2592
-
2593
- let raw;
2594
- try {
2595
- raw = JSON.parse(String(result.stdout || '{}'));
2596
- } catch {
2597
- printImessageLookupPayload({
2598
- ok: false,
2599
- action: 'imessage_lookup',
2600
- query: options.name,
2601
- error: 'Contacts lookup returned invalid JSON.',
2602
- }, options.json);
2603
- process.exit(1);
2604
- }
2605
-
2606
- const cachedAt = Date.now();
2607
- const latestCache = readImessageLookupCache();
2608
- latestCache.version = 1;
2609
- latestCache.entries = latestCache.entries || {};
2610
- latestCache.entries[key] = { cached_at: cachedAt, raw };
2611
- writeImessageLookupCache(latestCache);
2612
- printImessageLookupPayload(shapeImessageLookupPayload(raw, options, false, cachedAt), options.json);
2613
- }
2614
-
2615
- function parseImessageSendArgs(args) {
2616
- const outbound = extractOutboundGateOptions(args);
2617
- const options = {
2618
- approved: false,
2619
- json: false,
2620
- receipt: false,
2621
- to: '',
2622
- text: '',
2623
- outboundGate: outbound.gate,
2624
- };
2625
- const positional = [];
2626
- for (let i = 0; i < outbound.args.length; i += 1) {
2627
- const arg = outbound.args[i];
2628
- if (arg === '--approved' || arg === '--confirm-approved') {
2629
- options.approved = true;
2630
- } else if (arg === '--json') {
2631
- options.json = true;
2632
- } else if (arg === '--receipt') {
2633
- options.receipt = true;
2634
- } else if (arg === '--to' || arg === '--handle') {
2635
- options.to = outbound.args[i + 1] || '';
2636
- i += 1;
2637
- } else if (arg === '--text' || arg === '--message') {
2638
- options.text = outbound.args[i + 1] || '';
2639
- i += 1;
2640
- } else {
2641
- positional.push(arg);
2642
- }
2643
- }
2644
- if (!options.to && positional.length) options.to = positional.shift() || '';
2645
- if (!options.text && positional.length) options.text = positional.join(' ');
2646
- options.to = normalizeImessageHandle(options.to);
2647
- options.text = String(options.text || '').trim();
2648
- return options;
2649
- }
2650
-
2651
- function printImessageSendPayload(payload, json = false) {
2652
- if (json) {
2653
- console.log(JSON.stringify(payload, null, 2));
2654
- return;
2655
- }
2656
- if (payload.ok) {
2657
- console.log(`Sent iMessage to ${payload.to}.`);
2658
- if (payload.receipt_path) console.log(`Receipt: ${payload.receipt_path}`);
2659
- } else {
2660
- console.error(payload.error || 'Failed to send iMessage.');
2661
- }
2662
- }
2663
-
2664
- function writeImessageSendReceipt(payload) {
2665
- const atrisDir = path.join(process.cwd(), 'atris');
2666
- if (!fs.existsSync(atrisDir)) return '';
2667
- const runsDir = path.join(atrisDir, 'runs');
2668
- fs.mkdirSync(runsDir, { recursive: true });
2669
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
2670
- const receiptPath = path.join(runsDir, `imessage-send-${stamp}.md`);
2671
- const lines = [
2672
- '# iMessage Send Receipt',
2673
- '',
2674
- `- Sent at: ${payload.sent_at}`,
2675
- `- Provider: ${payload.provider}`,
2676
- `- To: ${payload.to}`,
2677
- `- Text: ${payload.text}`,
2678
- `- Doctor connected: ${payload.doctor?.connected === true}`,
2679
- `- Send exit: ${payload.osascript?.status}`,
2680
- `- DB verified: ${payload.db_verification?.matched === true}`,
2681
- `- DB settled: ${payload.db_verification?.settled === true}`,
2682
- ];
2683
- fs.writeFileSync(receiptPath, `${lines.join('\n')}\n`, 'utf8');
2684
- return receiptPath;
2685
- }
2686
-
2687
- function imessageSend(args = []) {
2688
- const options = parseImessageSendArgs(args);
2689
- const basePayload = {
2690
- ok: false,
2691
- action: 'imessage_send',
2692
- provider: 'local_imessage',
2693
- to: options.to,
2694
- text: options.text,
2695
- approved: options.approved,
2696
- };
2697
-
2698
- if (!options.to || (!options.text && !options.outboundGate.bodyFile)) {
2699
- printImessageSendPayload({
2700
- ...basePayload,
2701
- error: 'Usage: atris imessage send --to <phone-or-email> --text <message> --approved [--json] [--receipt]',
2702
- }, options.json);
2703
- process.exit(1);
2704
- }
2705
-
2706
- if (!options.approved) {
2707
- printImessageSendPayload({
2708
- ...basePayload,
2709
- error: 'Refusing to send without --approved after the exact recipient and exact text are confirmed.',
2710
- }, options.json);
2711
- process.exit(1);
2712
- }
2713
-
2714
- const gateResult = assertOutboundSendAllowedOrExit('imessage', options.text, options.outboundGate, {
2715
- json: options.json,
2716
- payload: basePayload,
2717
- });
2718
- options.text = gateResult.body;
2719
- basePayload.text = options.text;
2720
- if (!options.text) {
2721
- printImessageSendPayload({
2722
- ...basePayload,
2723
- error: 'Usage: atris imessage send --to <phone-or-email> --text <message> --approved [--json] [--receipt]',
2724
- }, options.json);
2725
- process.exit(1);
2726
- }
2727
-
2728
- const doctor = imessageDoctor();
2729
- if (!doctor.connected) {
2730
- printImessageSendPayload({
2731
- ...basePayload,
2732
- doctor,
2733
- error: 'iMessage is not available on this Mac.',
2734
- }, options.json);
2735
- process.exit(1);
2736
- }
2737
-
2738
- const sendStartedAt = Date.now();
2739
- const result = spawnSync('osascript', [
2740
- '-e', 'on run argv',
2741
- '-e', 'set targetHandle to item 1 of argv',
2742
- '-e', 'set messageText to item 2 of argv',
2743
- '-e', 'tell application "Messages"',
2744
- '-e', 'set targetService to 1st service whose service type = iMessage',
2745
- '-e', 'set targetBuddy to buddy targetHandle of targetService',
2746
- '-e', 'send messageText to targetBuddy',
2747
- '-e', 'end tell',
2748
- '-e', 'return targetHandle',
2749
- '-e', 'end run',
2750
- options.to,
2751
- options.text,
2752
- ], {
2753
- encoding: 'utf8',
2754
- timeout: 10000,
2755
- });
2756
-
2757
- const payload = {
2758
- ...basePayload,
2759
- ok: result.status === 0,
2760
- sent_at: new Date().toISOString(),
2761
- doctor: {
2762
- connected: doctor.connected,
2763
- checks: doctor.checks,
2764
- },
2765
- osascript: {
2766
- status: result.status,
2767
- signal: result.signal || null,
2768
- stdout: String(result.stdout || '').trim(),
2769
- stderr: String(result.stderr || '').trim(),
2770
- },
2771
- };
2772
-
2773
- if (payload.ok) {
2774
- payload.db_verification = imessageVerifyLatestOutgoing(options.to, sendStartedAt);
2775
- }
2776
-
2777
- if (payload.ok && options.receipt) {
2778
- payload.receipt_path = writeImessageSendReceipt(payload);
2779
- }
2780
-
2781
- if (!payload.ok) {
2782
- payload.error = payload.osascript.stderr || 'Messages AppleScript send failed.';
2783
- }
2784
-
2785
- printImessageSendPayload(payload, options.json);
2786
- if (!payload.ok) process.exit(1);
2787
- }
2788
-
2789
1242
  async function imessageCommand(subcommand, ...args) {
2790
1243
  switch (subcommand) {
2791
1244
  case 'doctor': {