nothumanallowed 6.8.4 → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nothumanallowed",
3
- "version": "6.8.4",
3
+ "version": "7.1.0",
4
4
  "description": "NotHumanAllowed — 38 AI agents for security, code, DevOps, data & daily ops. Per-agent memory, Telegram + Discord auto-responder, proactive intelligence daemon, voice chat, plugin system.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -111,7 +111,34 @@ TOOLS:
111
111
  Delete an email. Query can be a messageId OR a search term like "pranzo from:me in:sent".
112
112
  The system finds the matching email and moves it to Trash. ALWAYS confirm before deleting.
113
113
 
114
- 21. maps_directions(from: string, to: string)
114
+ 21. contact_add(name: string, email?: string, phone?: string, company?: string, address?: string)
115
+ Add a new contact to Google Contacts.
116
+
117
+ 22. contact_update(query: string, email?: string, phone?: string, company?: string, address?: string)
118
+ Update an existing contact. Query is the contact name to search for.
119
+
120
+ 23. contact_delete(query: string)
121
+ Delete a contact by name. ALWAYS confirm before deleting.
122
+
123
+ 24. contact_search(query: string)
124
+ Search contacts by name, email or phone. Returns matching contacts.
125
+
126
+ 25. gtask_list()
127
+ List Google Tasks (not completed).
128
+
129
+ 26. gtask_add(title: string, notes?: string, due?: string)
130
+ Add a Google Task. due is YYYY-MM-DD.
131
+
132
+ 27. gtask_complete(title: string)
133
+ Complete a Google Task by title.
134
+
135
+ 28. note_add(title: string, content?: string)
136
+ Create a local note.
137
+
138
+ 29. note_list()
139
+ List all local notes.
140
+
141
+ 30. maps_directions(from: string, to: string)
115
142
  Generate a Google Maps directions link between two locations.
116
143
 
117
144
  RULES:
@@ -417,6 +444,72 @@ async function executeTool(action, params, config) {
417
444
  await gm.trashMessage(cfg, messageId);
418
445
  return `Email moved to Trash.`;
419
446
  }
447
+ // ── Contacts CRUD ──────────────────────────────────────────────────
448
+ case 'contact_add': {
449
+ const gc = await import('../services/google-contacts.mjs');
450
+ const contact = await gc.createContact(config, {
451
+ name: params.name, email: params.email, phone: params.phone,
452
+ company: params.company, address: params.address,
453
+ });
454
+ return `Contact created: ${contact.name}${contact.phone ? ' — ' + contact.phone : ''}${contact.email ? ' — ' + contact.email : ''}`;
455
+ }
456
+ case 'contact_update': {
457
+ const gc = await import('../services/google-contacts.mjs');
458
+ const matches = await gc.searchContacts(config, params.query, 1);
459
+ if (matches.length === 0) return `No contact found matching "${params.query}".`;
460
+ const updated = await gc.updateContact(config, matches[0].resourceName, {
461
+ email: params.email, phone: params.phone, company: params.company, address: params.address,
462
+ });
463
+ return `Contact updated: ${updated.name}`;
464
+ }
465
+ case 'contact_delete': {
466
+ const gc = await import('../services/google-contacts.mjs');
467
+ const matches = await gc.searchContacts(config, params.query, 1);
468
+ if (matches.length === 0) return `No contact found matching "${params.query}".`;
469
+ await gc.deleteContact(config, matches[0].resourceName);
470
+ return `Contact "${matches[0].name}" deleted.`;
471
+ }
472
+ case 'contact_search': {
473
+ const gc = await import('../services/google-contacts.mjs');
474
+ const contacts = await gc.searchContacts(config, params.query, 10);
475
+ if (contacts.length === 0) return `No contacts matching "${params.query}".`;
476
+ return contacts.map((c, i) => `${i + 1}. ${c.name}${c.email ? ' — ' + c.email : ''}${c.phone ? ' — ' + c.phone : ''}${c.company ? ' (' + c.company + ')' : ''}`).join('\n');
477
+ }
478
+ // ── Google Tasks CRUD ────────────────────────────────────────────────
479
+ case 'gtask_list': {
480
+ const gt = await import('../services/google-tasks.mjs');
481
+ const tasks = await gt.listTasks(config);
482
+ if (tasks.length === 0) return 'No active Google Tasks.';
483
+ return tasks.map((t, i) => `${i + 1}. ${t.title}${t.due ? ' (due: ' + t.due.split('T')[0] + ')' : ''}${t.notes ? ' — ' + t.notes.slice(0, 80) : ''}`).join('\n');
484
+ }
485
+ case 'gtask_add': {
486
+ const gt = await import('../services/google-tasks.mjs');
487
+ const lists = await gt.listTaskLists(config);
488
+ const listId = lists[0]?.id || '@default';
489
+ const task = await gt.createTask(config, listId, params.title, params.notes || '', params.due || '');
490
+ return `Google Task created: "${task.title}"${task.due ? ' (due: ' + task.due.split('T')[0] + ')' : ''}`;
491
+ }
492
+ case 'gtask_complete': {
493
+ const gt = await import('../services/google-tasks.mjs');
494
+ const tasks = await gt.listTasks(config);
495
+ const match = tasks.find(t => t.title.toLowerCase().includes((params.title || '').toLowerCase()));
496
+ if (!match) return `No task found matching "${params.title}".`;
497
+ await gt.completeTask(config, match.listId || '@default', match.id);
498
+ return `Task "${match.title}" completed.`;
499
+ }
500
+ // ── Notes CRUD ───────────────────────────────────────────────────────
501
+ case 'note_add': {
502
+ const ns = await import('../services/notes.mjs');
503
+ const note = ns.createNote(params.title || 'Untitled', params.content || '', params.tags || []);
504
+ return `Note created: "${note.title}"`;
505
+ }
506
+ case 'note_list': {
507
+ const ns = await import('../services/notes.mjs');
508
+ const notes = ns.listNotes();
509
+ if (notes.length === 0) return 'No notes.';
510
+ return notes.map((n, i) => `${i + 1}. ${n.title} (${new Date(n.updatedAt).toLocaleDateString()})`).join('\n');
511
+ }
512
+ // ── Maps ─────────────────────────────────────────────────────────────
420
513
  case 'maps_directions': {
421
514
  const from = encodeURIComponent(params.from || '');
422
515
  const to = encodeURIComponent(params.to || '');
@@ -709,6 +802,106 @@ export async function cmdUI(args) {
709
802
  return;
710
803
  }
711
804
 
805
+ // POST /api/contacts — create contact
806
+ if (method === 'POST' && pathname === '/api/contacts') {
807
+ try {
808
+ const gc = await import('../services/google-contacts.mjs');
809
+ const body = await parseBody(req);
810
+ const contact = await gc.createContact(config, body);
811
+ sendJSON(res, 201, { contact });
812
+ } catch (e) {
813
+ sendJSON(res, 200, { error: e.message });
814
+ }
815
+ logRequest(method, pathname, 201, Date.now() - start);
816
+ return;
817
+ }
818
+
819
+ // DELETE /api/contacts/:resourceName
820
+ if (method === 'POST' && pathname.startsWith('/api/contacts/delete/')) {
821
+ try {
822
+ const gc = await import('../services/google-contacts.mjs');
823
+ const rn = pathname.replace('/api/contacts/delete/', '');
824
+ await gc.deleteContact(config, 'people/' + rn);
825
+ sendJSON(res, 200, { ok: true });
826
+ } catch (e) {
827
+ sendJSON(res, 200, { error: e.message });
828
+ }
829
+ logRequest(method, pathname, 200, Date.now() - start);
830
+ return;
831
+ }
832
+
833
+ // GET /api/contacts — list or search contacts
834
+ if (method === 'GET' && pathname === '/api/contacts') {
835
+ try {
836
+ const gc = await import('../services/google-contacts.mjs');
837
+ const q = url.searchParams.get('q');
838
+ let contacts;
839
+ if (q) {
840
+ contacts = await gc.searchContacts(config, q, 20);
841
+ } else {
842
+ contacts = await gc.listContacts(config, 50);
843
+ }
844
+ sendJSON(res, 200, { contacts });
845
+ } catch (e) {
846
+ sendJSON(res, 200, { contacts: [], error: e.message });
847
+ }
848
+ logRequest(method, pathname, 200, Date.now() - start);
849
+ return;
850
+ }
851
+
852
+ // GET /api/notes — list notes | POST /api/notes — create note
853
+ if (pathname === '/api/notes' && !pathname.includes('/api/notes/')) {
854
+ if (method === 'GET') {
855
+ try {
856
+ const ns = await import('../services/notes.mjs');
857
+ const q = url.searchParams.get('q');
858
+ const notes = q ? ns.searchNotes(q) : ns.listNotes();
859
+ sendJSON(res, 200, { notes });
860
+ } catch (e) {
861
+ sendJSON(res, 200, { notes: [], error: e.message });
862
+ }
863
+ logRequest(method, pathname, 200, Date.now() - start);
864
+ return;
865
+ }
866
+ if (method === 'POST') {
867
+ try {
868
+ const ns = await import('../services/notes.mjs');
869
+ const body = await parseBody(req);
870
+ const note = ns.createNote(body.title || 'Untitled', body.content || '', body.tags || []);
871
+ sendJSON(res, 201, { note });
872
+ } catch (e) {
873
+ sendJSON(res, 200, { error: e.message });
874
+ }
875
+ logRequest(method, pathname, 201, Date.now() - start);
876
+ return;
877
+ }
878
+ }
879
+
880
+ // GET /api/notes/:id — get note | POST /api/notes/:id — update | POST /api/notes/:id/delete — delete
881
+ const noteMatch = pathname.match(/^\/api\/notes\/([a-f0-9-]+)(\/delete)?$/);
882
+ if (noteMatch) {
883
+ const noteId = noteMatch[1];
884
+ const isDelete = noteMatch[2] === '/delete';
885
+ try {
886
+ const ns = await import('../services/notes.mjs');
887
+ if (isDelete && method === 'POST') {
888
+ ns.deleteNote(noteId);
889
+ sendJSON(res, 200, { ok: true });
890
+ } else if (method === 'POST') {
891
+ const body = await parseBody(req);
892
+ const note = ns.updateNote(noteId, body.title, body.content, body.tags);
893
+ sendJSON(res, 200, { ok: !!note, note });
894
+ } else {
895
+ const note = ns.getNote(noteId);
896
+ sendJSON(res, 200, { note });
897
+ }
898
+ } catch (e) {
899
+ sendJSON(res, 200, { error: e.message });
900
+ }
901
+ logRequest(method, pathname, 200, Date.now() - start);
902
+ return;
903
+ }
904
+
712
905
  // GET /api/drive — list recent Drive files
713
906
  if (method === 'GET' && pathname === '/api/drive') {
714
907
  try {
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '6.8.4';
8
+ export const VERSION = '7.1.0';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Google People API (Contacts) wrapper — zero dependencies.
3
+ * All calls via native fetch to People API v1.
4
+ * Auto-refreshes tokens on 401.
5
+ */
6
+
7
+ import { getAccessToken } from './token-store.mjs';
8
+
9
+ const PEOPLE_BASE = 'https://people.googleapis.com/v1';
10
+
11
+ const CONTACT_FIELDS = 'names,emailAddresses,phoneNumbers,organizations,birthdays,addresses';
12
+ const CONTACT_FIELDS_FULL = 'names,emailAddresses,phoneNumbers,organizations,birthdays,addresses,biographies,urls';
13
+
14
+ /** Authenticated fetch with auto-retry on 401 */
15
+ async function peopleFetch(config, urlPath, options = {}) {
16
+ const token = await getAccessToken(config);
17
+ const url = urlPath.startsWith('http') ? urlPath : `${PEOPLE_BASE}${urlPath}`;
18
+
19
+ let res = await fetch(url, {
20
+ ...options,
21
+ headers: {
22
+ 'Authorization': `Bearer ${token}`,
23
+ ...options.headers,
24
+ },
25
+ });
26
+
27
+ if (res.status === 401) {
28
+ const newToken = await getAccessToken(config);
29
+ res = await fetch(url, {
30
+ ...options,
31
+ headers: {
32
+ ...options.headers,
33
+ 'Authorization': `Bearer ${newToken}`,
34
+ },
35
+ });
36
+ }
37
+
38
+ if (!res.ok) {
39
+ const err = await res.text();
40
+ throw new Error(`People API ${res.status}: ${err}`);
41
+ }
42
+
43
+ const text = await res.text();
44
+ if (!text) return {};
45
+ try { return JSON.parse(text); } catch { return {}; }
46
+ }
47
+
48
+ /**
49
+ * Search contacts by name or email.
50
+ * @param {object} config
51
+ * @param {string} query — search term (name, email, phone)
52
+ * @param {number} maxResults
53
+ * @returns {Promise<Array>}
54
+ */
55
+ export async function searchContacts(config, query, maxResults = 10) {
56
+ const params = new URLSearchParams({
57
+ query,
58
+ readMask: CONTACT_FIELDS,
59
+ sources: 'READ_SOURCE_TYPE_CONTACT',
60
+ pageSize: String(maxResults),
61
+ });
62
+
63
+ const data = await peopleFetch(config, `/people:searchContacts?${params}`);
64
+ return (data.results || []).map(r => parseContact(r.person));
65
+ }
66
+
67
+ /**
68
+ * List all contacts sorted by first name.
69
+ * @param {object} config
70
+ * @param {number} maxResults
71
+ * @returns {Promise<Array>}
72
+ */
73
+ export async function listContacts(config, maxResults = 50) {
74
+ const params = new URLSearchParams({
75
+ personFields: CONTACT_FIELDS,
76
+ sortOrder: 'FIRST_NAME_ASCENDING',
77
+ pageSize: String(Math.min(maxResults, 1000)),
78
+ });
79
+
80
+ const contacts = [];
81
+ let pageToken = null;
82
+
83
+ do {
84
+ if (pageToken) params.set('pageToken', pageToken);
85
+ const data = await peopleFetch(config, `/people/me/connections?${params}`);
86
+ const connections = data.connections || [];
87
+ contacts.push(...connections.map(parseContact));
88
+ pageToken = data.nextPageToken || null;
89
+ } while (pageToken && contacts.length < maxResults);
90
+
91
+ return contacts.slice(0, maxResults);
92
+ }
93
+
94
+ /**
95
+ * Get a single contact by resourceName (e.g. "people/c1234567890").
96
+ * @param {object} config
97
+ * @param {string} resourceName
98
+ * @returns {Promise<object>}
99
+ */
100
+ export async function getContact(config, resourceName) {
101
+ const params = new URLSearchParams({
102
+ personFields: CONTACT_FIELDS_FULL,
103
+ });
104
+
105
+ const name = resourceName.startsWith('people/') ? resourceName : `people/${resourceName}`;
106
+ const data = await peopleFetch(config, `/${name}?${params}`);
107
+ return parseContact(data);
108
+ }
109
+
110
+ /**
111
+ * Get all contacts that have a birthday set.
112
+ * @param {object} config
113
+ * @returns {Promise<Array>}
114
+ */
115
+ export async function getBirthdays(config) {
116
+ const all = await listContacts(config, 1000);
117
+ return all.filter(c => c.birthday);
118
+ }
119
+
120
+ /**
121
+ * Create a new contact.
122
+ */
123
+ export async function createContact(config, { name, email, phone, company, title, address }) {
124
+ const body = { names: [], emailAddresses: [], phoneNumbers: [], organizations: [], addresses: [] };
125
+
126
+ if (name) {
127
+ const parts = name.split(' ');
128
+ body.names.push({ givenName: parts[0], familyName: parts.slice(1).join(' ') || '' });
129
+ }
130
+ if (email) body.emailAddresses.push({ value: email });
131
+ if (phone) body.phoneNumbers.push({ value: phone });
132
+ if (company || title) body.organizations.push({ name: company || '', title: title || '' });
133
+ if (address) body.addresses.push({ formattedValue: address });
134
+
135
+ const data = await peopleFetch(config, '/people:createContact', {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify(body),
139
+ });
140
+ return parseContact(data);
141
+ }
142
+
143
+ /**
144
+ * Update an existing contact.
145
+ */
146
+ export async function updateContact(config, resourceName, fields) {
147
+ // First get the current contact to get etag
148
+ const current = await peopleFetch(config, `/${resourceName}?personFields=names,emailAddresses,phoneNumbers,organizations,addresses`);
149
+
150
+ const body = { etag: current.etag };
151
+ const updateFields = [];
152
+
153
+ if (fields.name !== undefined) {
154
+ const parts = (fields.name || '').split(' ');
155
+ body.names = [{ givenName: parts[0], familyName: parts.slice(1).join(' ') || '' }];
156
+ updateFields.push('names');
157
+ }
158
+ if (fields.email !== undefined) {
159
+ body.emailAddresses = [{ value: fields.email }];
160
+ updateFields.push('emailAddresses');
161
+ }
162
+ if (fields.phone !== undefined) {
163
+ body.phoneNumbers = [{ value: fields.phone }];
164
+ updateFields.push('phoneNumbers');
165
+ }
166
+ if (fields.company !== undefined || fields.title !== undefined) {
167
+ body.organizations = [{ name: fields.company || '', title: fields.title || '' }];
168
+ updateFields.push('organizations');
169
+ }
170
+ if (fields.address !== undefined) {
171
+ body.addresses = [{ formattedValue: fields.address }];
172
+ updateFields.push('addresses');
173
+ }
174
+
175
+ const data = await peopleFetch(config, `/${resourceName}:updateContact?updatePersonFields=${updateFields.join(',')}`, {
176
+ method: 'PATCH',
177
+ headers: { 'Content-Type': 'application/json' },
178
+ body: JSON.stringify(body),
179
+ });
180
+ return parseContact(data);
181
+ }
182
+
183
+ /**
184
+ * Delete a contact.
185
+ */
186
+ export async function deleteContact(config, resourceName) {
187
+ await peopleFetch(config, `/${resourceName}:deleteContact`, { method: 'DELETE' });
188
+ return { ok: true };
189
+ }
190
+
191
+ // ── Helpers ─────────────────────────────────────────────────────────────
192
+
193
+ function parseContact(raw) {
194
+ if (!raw) return null;
195
+
196
+ const names = raw.names || [];
197
+ const primaryName = names.find(n => n.metadata?.primary) || names[0] || {};
198
+
199
+ const emailAddresses = raw.emailAddresses || [];
200
+ const primaryEmail = emailAddresses.find(e => e.metadata?.primary) || emailAddresses[0] || {};
201
+
202
+ const phoneNumbers = raw.phoneNumbers || [];
203
+ const primaryPhone = phoneNumbers.find(p => p.metadata?.primary) || phoneNumbers[0] || {};
204
+
205
+ const organizations = raw.organizations || [];
206
+ const primaryOrg = organizations.find(o => o.metadata?.primary) || organizations[0] || {};
207
+
208
+ const birthdays = raw.birthdays || [];
209
+ const primaryBirthday = birthdays.find(b => b.metadata?.primary) || birthdays[0];
210
+
211
+ const addresses = raw.addresses || [];
212
+ const primaryAddress = addresses.find(a => a.metadata?.primary) || addresses[0] || {};
213
+
214
+ return {
215
+ resourceName: raw.resourceName || '',
216
+ name: primaryName.displayName || '',
217
+ firstName: primaryName.givenName || '',
218
+ lastName: primaryName.familyName || '',
219
+ email: primaryEmail.value || '',
220
+ emails: emailAddresses.map(e => e.value).filter(Boolean),
221
+ phone: primaryPhone.value || '',
222
+ phones: phoneNumbers.map(p => p.value).filter(Boolean),
223
+ company: primaryOrg.name || '',
224
+ title: primaryOrg.title || '',
225
+ birthday: formatBirthday(primaryBirthday),
226
+ address: formatAddress(primaryAddress),
227
+ };
228
+ }
229
+
230
+ function formatBirthday(birthday) {
231
+ if (!birthday?.date) return '';
232
+ const { year, month, day } = birthday.date;
233
+ const mm = String(month).padStart(2, '0');
234
+ const dd = String(day).padStart(2, '0');
235
+ if (year) return `${year}-${mm}-${dd}`;
236
+ return `${mm}-${dd}`;
237
+ }
238
+
239
+ function formatAddress(address) {
240
+ if (!address) return '';
241
+ if (address.formattedValue) return address.formattedValue;
242
+ const parts = [
243
+ address.streetAddress,
244
+ address.city,
245
+ address.region,
246
+ address.postalCode,
247
+ address.country,
248
+ ].filter(Boolean);
249
+ return parts.join(', ');
250
+ }
@@ -22,6 +22,8 @@ const SCOPES = [
22
22
  'https://www.googleapis.com/auth/calendar.events',
23
23
  'https://www.googleapis.com/auth/drive.readonly',
24
24
  'https://www.googleapis.com/auth/drive.metadata.readonly',
25
+ 'https://www.googleapis.com/auth/contacts',
26
+ 'https://www.googleapis.com/auth/tasks',
25
27
  'https://www.googleapis.com/auth/userinfo.email',
26
28
  ].join(' ');
27
29
 
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Google Tasks API wrapper — zero dependencies.
3
+ * All calls via native fetch to Tasks REST API v1.
4
+ * Auto-refreshes tokens on 401.
5
+ */
6
+
7
+ import { getAccessToken } from './token-store.mjs';
8
+
9
+ const TASKS_BASE = 'https://tasks.googleapis.com/tasks/v1';
10
+
11
+ /** Authenticated fetch with auto-retry on 401 */
12
+ async function tasksFetch(config, urlPath, options = {}) {
13
+ const token = await getAccessToken(config);
14
+ const url = urlPath.startsWith('http') ? urlPath : `${TASKS_BASE}${urlPath}`;
15
+
16
+ let res = await fetch(url, {
17
+ ...options,
18
+ headers: {
19
+ 'Authorization': `Bearer ${token}`,
20
+ ...options.headers,
21
+ },
22
+ });
23
+
24
+ if (res.status === 401) {
25
+ const newToken = await getAccessToken(config);
26
+ res = await fetch(url, {
27
+ ...options,
28
+ headers: {
29
+ ...options.headers,
30
+ 'Authorization': `Bearer ${newToken}`,
31
+ },
32
+ });
33
+ }
34
+
35
+ if (!res.ok) {
36
+ const err = await res.text();
37
+ throw new Error(`Tasks API ${res.status}: ${err}`);
38
+ }
39
+
40
+ // DELETE returns 204 No Content
41
+ if (res.status === 204) return null;
42
+
43
+ return res.json();
44
+ }
45
+
46
+ /**
47
+ * List all task lists for the authenticated user.
48
+ * @param {object} config
49
+ * @returns {Promise<Array<{id: string, title: string, updated: string}>>}
50
+ */
51
+ export async function listTaskLists(config) {
52
+ const data = await tasksFetch(config, '/users/@me/lists');
53
+ return (data.items || []).map(item => ({
54
+ id: item.id,
55
+ title: item.title || '(untitled)',
56
+ updated: item.updated || '',
57
+ }));
58
+ }
59
+
60
+ /**
61
+ * List tasks in a task list.
62
+ * @param {object} config
63
+ * @param {string} taskListId — task list ID, defaults to '@default'
64
+ * @returns {Promise<Array>}
65
+ */
66
+ export async function listTasks(config, taskListId = '@default') {
67
+ const params = new URLSearchParams({
68
+ showCompleted: 'false',
69
+ maxResults: '100',
70
+ });
71
+
72
+ const data = await tasksFetch(config, `/lists/${encodeURIComponent(taskListId)}/tasks?${params}`);
73
+ return (data.items || []).map(item => parseTask(item, taskListId));
74
+ }
75
+
76
+ /**
77
+ * Create a new task.
78
+ * @param {object} config
79
+ * @param {string} taskListId
80
+ * @param {string} title
81
+ * @param {string} [notes]
82
+ * @param {string} [due] — RFC 3339 date-time string
83
+ * @returns {Promise<object>}
84
+ */
85
+ export async function createTask(config, taskListId, title, notes, due) {
86
+ const body = { title };
87
+ if (notes) body.notes = notes;
88
+ if (due) body.due = due;
89
+
90
+ const data = await tasksFetch(config, `/lists/${encodeURIComponent(taskListId)}/tasks`, {
91
+ method: 'POST',
92
+ headers: { 'Content-Type': 'application/json' },
93
+ body: JSON.stringify(body),
94
+ });
95
+
96
+ return parseTask(data, taskListId);
97
+ }
98
+
99
+ /**
100
+ * Mark a task as completed.
101
+ * @param {object} config
102
+ * @param {string} taskListId
103
+ * @param {string} taskId
104
+ * @returns {Promise<object>}
105
+ */
106
+ export async function completeTask(config, taskListId, taskId) {
107
+ const data = await tasksFetch(
108
+ config,
109
+ `/lists/${encodeURIComponent(taskListId)}/tasks/${encodeURIComponent(taskId)}`,
110
+ {
111
+ method: 'PATCH',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ status: 'completed' }),
114
+ },
115
+ );
116
+
117
+ return parseTask(data, taskListId);
118
+ }
119
+
120
+ /**
121
+ * Delete a task.
122
+ * @param {object} config
123
+ * @param {string} taskListId
124
+ * @param {string} taskId
125
+ * @returns {Promise<void>}
126
+ */
127
+ export async function deleteTask(config, taskListId, taskId) {
128
+ await tasksFetch(
129
+ config,
130
+ `/lists/${encodeURIComponent(taskListId)}/tasks/${encodeURIComponent(taskId)}`,
131
+ { method: 'DELETE' },
132
+ );
133
+ }
134
+
135
+ // ── Helpers ─────────────────────────────────────────────────────────────
136
+
137
+ function parseTask(raw, listId) {
138
+ return {
139
+ id: raw.id,
140
+ title: raw.title || '',
141
+ notes: raw.notes || '',
142
+ status: raw.status || 'needsAction',
143
+ due: raw.due || '',
144
+ updated: raw.updated || '',
145
+ listId: listId || '',
146
+ };
147
+ }