runline 0.37.0 → 0.38.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.
@@ -1,25 +1,254 @@
1
- import { credentialJson, pathSegment, } from "runline";
1
+ /**
2
+ * Todoist plugin for runline, on Todoist's unified API v1.
3
+ *
4
+ * Lists follow API v1's cursors (`next_cursor`, at most 200 a page) and
5
+ * answer one flat array; `limit` stops paging once that many are in.
6
+ * Failures report Todoist's `error_tag` (`UNAUTHORIZED`, `NOT_FOUND`).
7
+ */
8
+ import { credentialRequest, jsonAnswer, pathSegment, responseFailed, } from "runline";
9
+ import * as t from "typebox";
10
+ import { atLeastOne } from "../../_shared/schemas.js";
2
11
  import { todoistCredential } from "./credentials.js";
3
- function api(ctx, method, endpoint, body, qs) {
4
- return credentialJson(ctx, todoistCredential, "todoist", {
5
- target: "rest",
6
- path: endpoint.replace(/^\//, ""),
12
+ /** API v1's largest page. */
13
+ const PAGE = 200;
14
+ const STRICT = { additionalProperties: false };
15
+ const Id = (description) => t.String({ minLength: 1, ...(description ? { description } : {}) });
16
+ const Limit = t.Optional(t.Integer({ minimum: 1, description: "Stop after this many (default all)" }));
17
+ const Labels = t.Array(t.String({ minLength: 1 }), {
18
+ description: "Label names",
19
+ });
20
+ const Priority = t.Integer({
21
+ minimum: 1,
22
+ maximum: 4,
23
+ description: "1 (normal) to 4 (urgent)",
24
+ });
25
+ const DueDate = t.String({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" });
26
+ const Filter = (description) => t.String({ minLength: 1, maxLength: 1024, description });
27
+ const FilterLang = t.Optional(Id("IETF language tag the filter query is written in (default English); with filter"));
28
+ /** A language belongs to a filter query: `lang` only beside `filter`. */
29
+ const langWithFilter = {
30
+ anyOf: [{ required: ["filter"] }, { not: { required: ["lang"] } }],
31
+ };
32
+ const AssigneeId = t.String({
33
+ pattern: "^\\d+$",
34
+ description: "A user id, as project.getCollaborators lists it",
35
+ });
36
+ /** Todoist's color names, as API v1 lists them. */
37
+ const COLORS = [
38
+ "berry_red",
39
+ "red",
40
+ "orange",
41
+ "yellow",
42
+ "olive_green",
43
+ "lime_green",
44
+ "green",
45
+ "mint_green",
46
+ "teal",
47
+ "sky_blue",
48
+ "light_blue",
49
+ "blue",
50
+ "grape",
51
+ "violet",
52
+ "lavender",
53
+ "magenta",
54
+ "salmon",
55
+ "charcoal",
56
+ "grey",
57
+ "taupe",
58
+ ];
59
+ const Color = t.Unsafe(t.Union(COLORS.map((color) => t.Literal(color))));
60
+ const ViewStyle = t.Union([
61
+ t.Literal("list"),
62
+ t.Literal("board"),
63
+ t.Literal("calendar"),
64
+ ]);
65
+ /** Todoist's error envelope: only its `error_tag` is read. */
66
+ function todoistError(body) {
67
+ return { code: body?.error_tag };
68
+ }
69
+ /** One request; a failure reports the status and Todoist's error_tag. */
70
+ async function api(ctx, method, path, options = {}) {
71
+ const res = await credentialRequest(ctx, todoistCredential, {
72
+ target: "api",
73
+ path,
7
74
  method,
8
- query: qs,
9
- ...(body && Object.keys(body).length > 0 ? { json: body } : {}),
75
+ query: options.query,
76
+ ...(options.body ? { json: options.body } : {}),
10
77
  });
78
+ if (!res.ok)
79
+ throw await responseFailed("todoist", res, todoistError);
80
+ return jsonAnswer(res);
11
81
  }
12
- function quickAdd(ctx, body) {
13
- return credentialJson(ctx, todoistCredential, "todoist", {
14
- target: "sync",
15
- path: "quick/add",
16
- method: "POST",
17
- json: body,
18
- });
82
+ /** A request whose answer is an acknowledgement. */
83
+ async function act(ctx, method, path) {
84
+ await api(ctx, method, path);
85
+ return { success: true };
86
+ }
87
+ /** Every item of a cursor-paginated list; `limit` stops paging early. */
88
+ async function listAll(ctx, path, query = {}, limit, key = "results") {
89
+ const all = [];
90
+ let cursor;
91
+ do {
92
+ const page = (await api(ctx, "GET", path, {
93
+ query: {
94
+ ...query,
95
+ limit: limit ? Math.min(limit - all.length, PAGE) : PAGE,
96
+ cursor,
97
+ },
98
+ }));
99
+ all.push(...(page[key] ?? []));
100
+ cursor = page.next_cursor;
101
+ } while (cursor && (!limit || all.length < limit));
102
+ return limit ? all.slice(0, limit) : all;
103
+ }
104
+ /** The defined entries of `fields`, under their API names. */
105
+ function wire(fields, names) {
106
+ const out = {};
107
+ for (const [key, name] of Object.entries(names))
108
+ if (fields[key] !== undefined)
109
+ out[name] = fields[key];
110
+ return out;
111
+ }
112
+ /** Where a task sits, as API v1 names it. */
113
+ const placeNames = {
114
+ projectId: "project_id",
115
+ sectionId: "section_id",
116
+ parentId: "parent_id",
117
+ };
118
+ /** A task's fields as API v1 names them; assigneeId is sent as a number. */
119
+ function taskBody(fields) {
120
+ const body = wire(fields, {
121
+ content: "content",
122
+ description: "description",
123
+ ...placeNames,
124
+ priority: "priority",
125
+ dueString: "due_string",
126
+ dueDate: "due_date",
127
+ labels: "labels",
128
+ });
129
+ if (typeof fields.assigneeId === "string")
130
+ body.assignee_id = Number(fields.assigneeId);
131
+ return body;
19
132
  }
133
+ const byId = t.Object({ id: Id() }, STRICT);
134
+ const taskChanges = {
135
+ content: t.Optional(Id()),
136
+ description: t.Optional(t.String()),
137
+ priority: t.Optional(Priority),
138
+ dueString: t.Optional(Id('Natural language, e.g. "every monday"')),
139
+ dueDate: t.Optional(DueDate),
140
+ labels: t.Optional(Labels),
141
+ assigneeId: t.Optional(AssigneeId),
142
+ };
143
+ /** Where a task sits; placeNames gives the API's names. */
144
+ const taskPlace = {
145
+ projectId: t.Optional(Id()),
146
+ sectionId: t.Optional(Id()),
147
+ parentId: t.Optional(Id()),
148
+ };
149
+ const taskCreate = t.Object({ ...taskChanges, ...taskPlace, content: Id() }, STRICT);
150
+ const taskUpdate = t.Object({ id: Id(), ...taskChanges }, { ...STRICT, ...atLeastOne(taskChanges) });
151
+ const taskSelectors = { ...taskPlace, label: t.Optional(Id()) };
152
+ const taskList = t.Object({
153
+ ...taskSelectors,
154
+ filter: t.Optional(Filter('Todoist filter query, e.g. "today | overdue"; instead of the fields above')),
155
+ lang: FilterLang,
156
+ limit: Limit,
157
+ }, {
158
+ ...STRICT,
159
+ allOf: [
160
+ // A filter query replaces the selectors rather than narrowing them.
161
+ {
162
+ anyOf: [
163
+ { not: { required: ["filter"] } },
164
+ {
165
+ not: {
166
+ anyOf: Object.keys(taskSelectors).map((key) => ({
167
+ required: [key],
168
+ })),
169
+ },
170
+ },
171
+ ],
172
+ },
173
+ langWithFilter,
174
+ ],
175
+ });
176
+ const taskQuickAdd = t.Object({
177
+ text: Id('e.g. "Buy milk @Grocery #shopping tomorrow"'),
178
+ note: t.Optional(t.String()),
179
+ reminder: t.Optional(Id()),
180
+ autoReminder: t.Optional(t.Boolean({
181
+ description: "Add the user's default reminder when the task has a due time",
182
+ })),
183
+ }, STRICT);
184
+ const taskMove = t.Object({ id: Id(), ...taskPlace }, {
185
+ ...STRICT,
186
+ oneOf: Object.keys(taskPlace).map((key) => ({ required: [key] })),
187
+ });
188
+ const taskListCompleted = t.Object({
189
+ since: t.String({
190
+ format: "date-time",
191
+ description: "Start of the window, inclusive, e.g. 2026-05-01T00:00:00Z",
192
+ }),
193
+ until: t.String({
194
+ format: "date-time",
195
+ description: "End of the window, exclusive; at most 3 months after since",
196
+ }),
197
+ ...taskPlace,
198
+ filter: t.Optional(Filter("Todoist filter query the tasks must match")),
199
+ lang: FilterLang,
200
+ limit: Limit,
201
+ }, { ...STRICT, ...langWithFilter });
202
+ const projectChanges = {
203
+ name: t.Optional(Id()),
204
+ color: t.Optional(Color),
205
+ isFavorite: t.Optional(t.Boolean()),
206
+ viewStyle: t.Optional(ViewStyle),
207
+ };
208
+ const projectCreate = t.Object({ ...projectChanges, name: Id(), parentId: t.Optional(Id()) }, STRICT);
209
+ const projectUpdate = t.Object({ id: Id(), ...projectChanges }, { ...STRICT, ...atLeastOne(projectChanges) });
210
+ const sectionCreate = t.Object({
211
+ projectId: Id(),
212
+ name: Id(),
213
+ order: t.Optional(t.Integer({ description: "Position of the section in the project" })),
214
+ }, STRICT);
215
+ const sectionList = t.Object({ projectId: t.Optional(Id()) }, STRICT);
216
+ const sectionUpdate = t.Object({ id: Id(), name: Id() }, STRICT);
217
+ /** A comment belongs to a task or to a project, never both. */
218
+ const commentOwner = {
219
+ taskId: t.Optional(Id()),
220
+ projectId: t.Optional(Id()),
221
+ };
222
+ const ownerRule = {
223
+ oneOf: Object.keys(commentOwner).map((key) => ({ required: [key] })),
224
+ };
225
+ const commentCreate = t.Object({ ...commentOwner, content: Id() }, { ...STRICT, ...ownerRule });
226
+ const commentList = t.Object(commentOwner, { ...STRICT, ...ownerRule });
227
+ const commentUpdate = t.Object({ id: Id(), content: Id() }, STRICT);
228
+ const labelChanges = {
229
+ name: t.Optional(Id()),
230
+ color: t.Optional(Color),
231
+ order: t.Optional(t.Integer({ minimum: -32768, maximum: 32767 })),
232
+ isFavorite: t.Optional(t.Boolean()),
233
+ };
234
+ const labelCreate = t.Object({ ...labelChanges, name: Id() }, STRICT);
235
+ const labelUpdate = t.Object({ id: Id(), ...labelChanges }, { ...STRICT, ...atLeastOne(labelChanges) });
236
+ const projectNames = {
237
+ name: "name",
238
+ color: "color",
239
+ isFavorite: "is_favorite",
240
+ viewStyle: "view_style",
241
+ parentId: "parent_id",
242
+ };
243
+ const labelNames = {
244
+ name: "name",
245
+ color: "color",
246
+ order: "order",
247
+ isFavorite: "is_favorite",
248
+ };
20
249
  export default function todoist(rl) {
21
250
  rl.setName("todoist");
22
- rl.setVersion("0.1.0");
251
+ rl.setVersion("1.0.0");
23
252
  rl.setCredential(todoistCredential);
24
253
  rl.setConnectionSchema({
25
254
  apiToken: {
@@ -33,462 +262,342 @@ export default function todoist(rl) {
33
262
  rl.registerAction("task.create", {
34
263
  access: "write",
35
264
  description: "Create a task",
36
- inputSchema: {
37
- content: { type: "string", required: true },
38
- projectId: { type: "string", required: false },
39
- description: { type: "string", required: false },
40
- priority: {
41
- type: "number",
42
- required: false,
43
- description: "1 (normal) to 4 (urgent)",
44
- },
45
- dueString: { type: "string", required: false },
46
- dueDate: { type: "string", required: false, description: "YYYY-MM-DD" },
47
- labels: {
48
- type: "object",
49
- required: false,
50
- description: "Array of label names",
51
- },
52
- sectionId: { type: "string", required: false },
53
- parentId: { type: "string", required: false },
54
- assigneeId: { type: "string", required: false },
55
- },
265
+ inputSchema: taskCreate,
56
266
  async execute(input, ctx) {
57
- const p = input;
58
- const body = { content: p.content };
59
- if (p.projectId)
60
- body.project_id = p.projectId;
61
- if (p.description)
62
- body.description = p.description;
63
- if (p.priority)
64
- body.priority = p.priority;
65
- if (p.dueString)
66
- body.due_string = p.dueString;
67
- if (p.dueDate)
68
- body.due_date = p.dueDate;
69
- if (p.labels)
70
- body.labels = p.labels;
71
- if (p.sectionId)
72
- body.section_id = p.sectionId;
73
- if (p.parentId)
74
- body.parent_id = p.parentId;
75
- if (p.assigneeId)
76
- body.assignee_id = p.assigneeId;
77
- return api(ctx, "POST", "/tasks", body);
267
+ return api(ctx, "POST", "tasks", {
268
+ body: taskBody(input),
269
+ });
78
270
  },
79
271
  });
80
272
  rl.registerAction("task.get", {
81
273
  access: "read",
82
274
  description: "Get a task by ID",
83
- inputSchema: { id: { type: "string", required: true } },
275
+ inputSchema: byId,
84
276
  async execute(input, ctx) {
85
- return api(ctx, "GET", `/tasks/${pathSegment(input.id)}`);
277
+ const { id } = input;
278
+ return api(ctx, "GET", `tasks/${pathSegment(id)}`);
86
279
  },
87
280
  });
88
281
  rl.registerAction("task.list", {
89
282
  access: "read",
90
- description: "List tasks",
91
- inputSchema: {
92
- projectId: { type: "string", required: false },
93
- sectionId: { type: "string", required: false },
94
- label: { type: "string", required: false },
95
- filter: { type: "string", required: false },
96
- limit: { type: "number", required: false },
97
- },
283
+ description: "List active tasks, by project, section, parent or label, or by a Todoist filter query instead",
284
+ inputSchema: taskList,
98
285
  async execute(input, ctx) {
99
286
  const p = (input ?? {});
100
- const qs = {};
101
- if (p.projectId)
102
- qs.project_id = p.projectId;
103
- if (p.sectionId)
104
- qs.section_id = p.sectionId;
105
- if (p.label)
106
- qs.label = p.label;
107
- if (p.filter)
108
- qs.filter = p.filter;
109
- const data = (await api(ctx, "GET", "/tasks", undefined, qs));
110
- return p.limit ? data.slice(0, p.limit) : data;
287
+ if (p.filter !== undefined)
288
+ return listAll(ctx, "tasks/filter", { query: p.filter, lang: p.lang }, p.limit);
289
+ return listAll(ctx, "tasks", wire(p, { ...placeNames, label: "label" }), p.limit);
111
290
  },
112
291
  });
113
292
  rl.registerAction("task.update", {
114
293
  access: "write",
115
- description: "Update a task",
116
- inputSchema: {
117
- id: { type: "string", required: true },
118
- content: { type: "string", required: false },
119
- description: { type: "string", required: false },
120
- priority: { type: "number", required: false },
121
- dueString: { type: "string", required: false },
122
- dueDate: { type: "string", required: false },
123
- labels: { type: "object", required: false },
124
- assigneeId: { type: "string", required: false },
125
- },
294
+ description: "Update a task; only the fields given change",
295
+ inputSchema: taskUpdate,
126
296
  async execute(input, ctx) {
127
297
  const { id, ...fields } = input;
128
- const body = {};
129
- if (fields.content)
130
- body.content = fields.content;
131
- if (fields.description)
132
- body.description = fields.description;
133
- if (fields.priority)
134
- body.priority = fields.priority;
135
- if (fields.dueString)
136
- body.due_string = fields.dueString;
137
- if (fields.dueDate)
138
- body.due_date = fields.dueDate;
139
- if (fields.labels)
140
- body.labels = fields.labels;
141
- if (fields.assigneeId)
142
- body.assignee_id = fields.assigneeId;
143
- return api(ctx, "POST", `/tasks/${pathSegment(id)}`, body);
298
+ return api(ctx, "POST", `tasks/${pathSegment(id)}`, {
299
+ body: taskBody(fields),
300
+ });
144
301
  },
145
302
  });
146
303
  rl.registerAction("task.close", {
147
304
  access: "write",
148
305
  description: "Close (complete) a task",
149
- inputSchema: { id: { type: "string", required: true } },
306
+ inputSchema: byId,
150
307
  async execute(input, ctx) {
151
- await api(ctx, "POST", `/tasks/${pathSegment(input.id)}/close`);
152
- return { success: true };
308
+ const { id } = input;
309
+ return act(ctx, "POST", `tasks/${pathSegment(id)}/close`);
153
310
  },
154
311
  });
155
312
  rl.registerAction("task.reopen", {
156
313
  access: "write",
157
314
  description: "Reopen a task",
158
- inputSchema: { id: { type: "string", required: true } },
315
+ inputSchema: byId,
159
316
  async execute(input, ctx) {
160
- await api(ctx, "POST", `/tasks/${pathSegment(input.id)}/reopen`);
161
- return { success: true };
317
+ const { id } = input;
318
+ return act(ctx, "POST", `tasks/${pathSegment(id)}/reopen`);
162
319
  },
163
320
  });
164
321
  rl.registerAction("task.delete", {
165
322
  access: "write",
166
323
  description: "Delete a task",
167
- inputSchema: { id: { type: "string", required: true } },
324
+ inputSchema: byId,
168
325
  async execute(input, ctx) {
169
- await api(ctx, "DELETE", `/tasks/${pathSegment(input.id)}`);
170
- return { success: true };
326
+ const { id } = input;
327
+ return act(ctx, "DELETE", `tasks/${pathSegment(id)}`);
171
328
  },
172
329
  });
173
330
  rl.registerAction("task.quickAdd", {
174
331
  access: "write",
175
332
  description: "Quick add a task using natural language",
176
- inputSchema: {
177
- text: {
178
- type: "string",
179
- required: true,
180
- description: 'e.g. "Buy milk @Grocery #shopping tomorrow"',
181
- },
182
- note: { type: "string", required: false },
183
- reminder: { type: "string", required: false },
333
+ inputSchema: taskQuickAdd,
334
+ async execute(input, ctx) {
335
+ const p = input;
336
+ return api(ctx, "POST", "tasks/quick", {
337
+ body: wire(p, {
338
+ text: "text",
339
+ note: "note",
340
+ reminder: "reminder",
341
+ autoReminder: "auto_reminder",
342
+ }),
343
+ });
344
+ },
345
+ });
346
+ rl.registerAction("task.move", {
347
+ access: "write",
348
+ description: "Move a task to another project, section or parent task (exactly one)",
349
+ inputSchema: taskMove,
350
+ async execute(input, ctx) {
351
+ const { id, ...place } = input;
352
+ return api(ctx, "POST", `tasks/${pathSegment(id)}/move`, {
353
+ body: wire(place, placeNames),
354
+ });
184
355
  },
356
+ });
357
+ rl.registerAction("task.listCompleted", {
358
+ access: "read",
359
+ description: "List tasks completed in a time window of at most 3 months, by completion date",
360
+ inputSchema: taskListCompleted,
185
361
  async execute(input, ctx) {
186
362
  const p = input;
187
- const body = { text: p.text };
188
- if (p.note)
189
- body.note = p.note;
190
- if (p.reminder)
191
- body.reminder = p.reminder;
192
- return quickAdd(ctx, body);
363
+ return listAll(ctx, "tasks/completed/by_completion_date", wire(p, {
364
+ since: "since",
365
+ until: "until",
366
+ ...placeNames,
367
+ filter: "filter_query",
368
+ lang: "filter_lang",
369
+ }), p.limit, "items");
193
370
  },
194
371
  });
195
372
  // ── Project ─────────────────────────────────────────
196
373
  rl.registerAction("project.create", {
197
374
  access: "write",
198
375
  description: "Create a project",
199
- inputSchema: {
200
- name: { type: "string", required: true },
201
- color: { type: "string", required: false },
202
- isFavorite: { type: "boolean", required: false },
203
- parentId: { type: "string", required: false },
204
- viewStyle: {
205
- type: "string",
206
- required: false,
207
- description: "list or board",
208
- },
209
- },
376
+ inputSchema: projectCreate,
210
377
  async execute(input, ctx) {
211
- const p = input;
212
- const body = { name: p.name };
213
- if (p.color)
214
- body.color = p.color;
215
- if (p.isFavorite)
216
- body.is_favorite = true;
217
- if (p.parentId)
218
- body.parent_id = p.parentId;
219
- if (p.viewStyle)
220
- body.view_style = p.viewStyle;
221
- return api(ctx, "POST", "/projects", body);
378
+ return api(ctx, "POST", "projects", {
379
+ body: wire(input, projectNames),
380
+ });
222
381
  },
223
382
  });
224
383
  rl.registerAction("project.get", {
225
384
  access: "read",
226
385
  description: "Get a project",
227
- inputSchema: { id: { type: "string", required: true } },
386
+ inputSchema: byId,
228
387
  async execute(input, ctx) {
229
- return api(ctx, "GET", `/projects/${pathSegment(input.id)}`);
388
+ const { id } = input;
389
+ return api(ctx, "GET", `projects/${pathSegment(id)}`);
230
390
  },
231
391
  });
232
392
  rl.registerAction("project.list", {
233
393
  access: "read",
234
394
  description: "List all projects",
235
- inputSchema: {},
395
+ inputSchema: t.Object({}, STRICT),
236
396
  async execute(_input, ctx) {
237
- return api(ctx, "GET", "/projects");
397
+ return listAll(ctx, "projects");
238
398
  },
239
399
  });
240
400
  rl.registerAction("project.update", {
241
401
  access: "write",
242
- description: "Update a project",
243
- inputSchema: {
244
- id: { type: "string", required: true },
245
- name: { type: "string", required: false },
246
- color: { type: "string", required: false },
247
- isFavorite: { type: "boolean", required: false },
248
- viewStyle: { type: "string", required: false },
249
- },
402
+ description: "Update a project; only the fields given change",
403
+ inputSchema: projectUpdate,
250
404
  async execute(input, ctx) {
251
405
  const { id, ...fields } = input;
252
- const body = {};
253
- if (fields.name)
254
- body.name = fields.name;
255
- if (fields.color)
256
- body.color = fields.color;
257
- if (fields.isFavorite !== undefined)
258
- body.is_favorite = fields.isFavorite;
259
- if (fields.viewStyle)
260
- body.view_style = fields.viewStyle;
261
- return api(ctx, "POST", `/projects/${pathSegment(id)}`, body);
406
+ return api(ctx, "POST", `projects/${pathSegment(id)}`, {
407
+ body: wire(fields, projectNames),
408
+ });
262
409
  },
263
410
  });
264
411
  rl.registerAction("project.delete", {
265
412
  access: "write",
266
413
  description: "Delete a project",
267
- inputSchema: { id: { type: "string", required: true } },
414
+ inputSchema: byId,
268
415
  async execute(input, ctx) {
269
- await api(ctx, "DELETE", `/projects/${pathSegment(input.id)}`);
270
- return { success: true };
416
+ const { id } = input;
417
+ return act(ctx, "DELETE", `projects/${pathSegment(id)}`);
271
418
  },
272
419
  });
273
420
  rl.registerAction("project.archive", {
274
421
  access: "write",
275
422
  description: "Archive a project",
276
- inputSchema: { id: { type: "string", required: true } },
423
+ inputSchema: byId,
277
424
  async execute(input, ctx) {
278
- await api(ctx, "POST", `/projects/${pathSegment(input.id)}/archive`);
279
- return { success: true };
425
+ const { id } = input;
426
+ return act(ctx, "POST", `projects/${pathSegment(id)}/archive`);
280
427
  },
281
428
  });
282
429
  rl.registerAction("project.unarchive", {
283
430
  access: "write",
284
431
  description: "Unarchive a project",
285
- inputSchema: { id: { type: "string", required: true } },
432
+ inputSchema: byId,
286
433
  async execute(input, ctx) {
287
- await api(ctx, "POST", `/projects/${pathSegment(input.id)}/unarchive`);
288
- return { success: true };
434
+ const { id } = input;
435
+ return act(ctx, "POST", `projects/${pathSegment(id)}/unarchive`);
289
436
  },
290
437
  });
291
438
  rl.registerAction("project.getCollaborators", {
292
439
  access: "read",
293
- description: "Get project collaborators",
294
- inputSchema: { id: { type: "string", required: true } },
440
+ description: "Get project collaborators: [{id,name,email}]",
441
+ inputSchema: byId,
295
442
  async execute(input, ctx) {
296
- return api(ctx, "GET", `/projects/${pathSegment(input.id)}/collaborators`);
443
+ const { id } = input;
444
+ return listAll(ctx, `projects/${pathSegment(id)}/collaborators`);
297
445
  },
298
446
  });
299
447
  // ── Section ─────────────────────────────────────────
300
448
  rl.registerAction("section.create", {
301
449
  access: "write",
302
450
  description: "Create a section",
303
- inputSchema: {
304
- projectId: { type: "string", required: true },
305
- name: { type: "string", required: true },
306
- order: { type: "number", required: false },
307
- },
451
+ inputSchema: sectionCreate,
308
452
  async execute(input, ctx) {
309
453
  const p = input;
310
- return api(ctx, "POST", "/sections", {
311
- project_id: p.projectId,
312
- name: p.name,
313
- ...(p.order ? { order: p.order } : {}),
454
+ return api(ctx, "POST", "sections", {
455
+ body: wire(p, {
456
+ projectId: "project_id",
457
+ name: "name",
458
+ order: "order",
459
+ }),
314
460
  });
315
461
  },
316
462
  });
317
463
  rl.registerAction("section.get", {
318
464
  access: "read",
319
465
  description: "Get a section",
320
- inputSchema: { id: { type: "string", required: true } },
466
+ inputSchema: byId,
321
467
  async execute(input, ctx) {
322
- return api(ctx, "GET", `/sections/${pathSegment(input.id)}`);
468
+ const { id } = input;
469
+ return api(ctx, "GET", `sections/${pathSegment(id)}`);
323
470
  },
324
471
  });
325
472
  rl.registerAction("section.list", {
326
473
  access: "read",
327
- description: "List sections",
328
- inputSchema: { projectId: { type: "string", required: false } },
474
+ description: "List sections, of one project or all",
475
+ inputSchema: sectionList,
329
476
  async execute(input, ctx) {
330
- const qs = {};
331
- if (input?.projectId)
332
- qs.project_id = input.projectId;
333
- return api(ctx, "GET", "/sections", undefined, qs);
477
+ const p = (input ?? {});
478
+ return listAll(ctx, "sections", wire(p, { projectId: "project_id" }));
334
479
  },
335
480
  });
336
481
  rl.registerAction("section.update", {
337
482
  access: "write",
338
- description: "Update a section",
339
- inputSchema: {
340
- id: { type: "string", required: true },
341
- name: { type: "string", required: true },
342
- },
483
+ description: "Rename a section",
484
+ inputSchema: sectionUpdate,
343
485
  async execute(input, ctx) {
344
486
  const p = input;
345
- return api(ctx, "POST", `/sections/${pathSegment(p.id)}`, {
346
- name: p.name,
487
+ return api(ctx, "POST", `sections/${pathSegment(p.id)}`, {
488
+ body: { name: p.name },
347
489
  });
348
490
  },
349
491
  });
350
492
  rl.registerAction("section.delete", {
351
493
  access: "write",
352
494
  description: "Delete a section",
353
- inputSchema: { id: { type: "string", required: true } },
495
+ inputSchema: byId,
354
496
  async execute(input, ctx) {
355
- await api(ctx, "DELETE", `/sections/${pathSegment(input.id)}`);
356
- return { success: true };
497
+ const { id } = input;
498
+ return act(ctx, "DELETE", `sections/${pathSegment(id)}`);
357
499
  },
358
500
  });
359
501
  // ── Comment ─────────────────────────────────────────
360
502
  rl.registerAction("comment.create", {
361
503
  access: "write",
362
- description: "Create a comment on a task",
363
- inputSchema: {
364
- taskId: { type: "string", required: true },
365
- content: { type: "string", required: true },
366
- },
504
+ description: "Comment on a task or on a project (exactly one)",
505
+ inputSchema: commentCreate,
367
506
  async execute(input, ctx) {
368
507
  const p = input;
369
- return api(ctx, "POST", "/comments", {
370
- task_id: p.taskId,
371
- content: p.content,
508
+ return api(ctx, "POST", "comments", {
509
+ body: wire(p, {
510
+ taskId: "task_id",
511
+ projectId: "project_id",
512
+ content: "content",
513
+ }),
372
514
  });
373
515
  },
374
516
  });
375
517
  rl.registerAction("comment.get", {
376
518
  access: "read",
377
519
  description: "Get a comment",
378
- inputSchema: { id: { type: "string", required: true } },
520
+ inputSchema: byId,
379
521
  async execute(input, ctx) {
380
- return api(ctx, "GET", `/comments/${pathSegment(input.id)}`);
522
+ const { id } = input;
523
+ return api(ctx, "GET", `comments/${pathSegment(id)}`);
381
524
  },
382
525
  });
383
526
  rl.registerAction("comment.list", {
384
527
  access: "read",
385
- description: "List comments",
386
- inputSchema: {
387
- taskId: { type: "string", required: false },
388
- projectId: { type: "string", required: false },
389
- },
528
+ description: "List the comments of a task or of a project (exactly one)",
529
+ inputSchema: commentList,
390
530
  async execute(input, ctx) {
391
- const p = (input ?? {});
392
- const qs = {};
393
- if (p.taskId)
394
- qs.task_id = p.taskId;
395
- if (p.projectId)
396
- qs.project_id = p.projectId;
397
- return api(ctx, "GET", "/comments", undefined, qs);
531
+ const p = input;
532
+ return listAll(ctx, "comments", wire(p, { taskId: "task_id", projectId: "project_id" }));
398
533
  },
399
534
  });
400
535
  rl.registerAction("comment.update", {
401
536
  access: "write",
402
537
  description: "Update a comment",
403
- inputSchema: {
404
- id: { type: "string", required: true },
405
- content: { type: "string", required: true },
406
- },
538
+ inputSchema: commentUpdate,
407
539
  async execute(input, ctx) {
408
540
  const p = input;
409
- return api(ctx, "POST", `/comments/${pathSegment(p.id)}`, {
410
- content: p.content,
541
+ return api(ctx, "POST", `comments/${pathSegment(p.id)}`, {
542
+ body: { content: p.content },
411
543
  });
412
544
  },
413
545
  });
414
546
  rl.registerAction("comment.delete", {
415
547
  access: "write",
416
548
  description: "Delete a comment",
417
- inputSchema: { id: { type: "string", required: true } },
549
+ inputSchema: byId,
418
550
  async execute(input, ctx) {
419
- await api(ctx, "DELETE", `/comments/${pathSegment(input.id)}`);
420
- return { success: true };
551
+ const { id } = input;
552
+ return act(ctx, "DELETE", `comments/${pathSegment(id)}`);
421
553
  },
422
554
  });
423
555
  // ── Label ───────────────────────────────────────────
424
556
  rl.registerAction("label.create", {
425
557
  access: "write",
426
558
  description: "Create a label",
427
- inputSchema: {
428
- name: { type: "string", required: true },
429
- color: { type: "string", required: false },
430
- order: { type: "number", required: false },
431
- isFavorite: { type: "boolean", required: false },
432
- },
559
+ inputSchema: labelCreate,
433
560
  async execute(input, ctx) {
434
- const p = input;
435
- const body = { name: p.name };
436
- if (p.color)
437
- body.color = p.color;
438
- if (p.order)
439
- body.order = p.order;
440
- if (p.isFavorite)
441
- body.is_favorite = true;
442
- return api(ctx, "POST", "/labels", body);
561
+ return api(ctx, "POST", "labels", {
562
+ body: wire(input, labelNames),
563
+ });
443
564
  },
444
565
  });
445
566
  rl.registerAction("label.get", {
446
567
  access: "read",
447
568
  description: "Get a label",
448
- inputSchema: { id: { type: "string", required: true } },
569
+ inputSchema: byId,
449
570
  async execute(input, ctx) {
450
- return api(ctx, "GET", `/labels/${pathSegment(input.id)}`);
571
+ const { id } = input;
572
+ return api(ctx, "GET", `labels/${pathSegment(id)}`);
451
573
  },
452
574
  });
453
575
  rl.registerAction("label.list", {
454
576
  access: "read",
455
577
  description: "List all labels",
456
- inputSchema: {},
578
+ inputSchema: t.Object({}, STRICT),
457
579
  async execute(_input, ctx) {
458
- return api(ctx, "GET", "/labels");
580
+ return listAll(ctx, "labels");
459
581
  },
460
582
  });
461
583
  rl.registerAction("label.update", {
462
584
  access: "write",
463
- description: "Update a label",
464
- inputSchema: {
465
- id: { type: "string", required: true },
466
- name: { type: "string", required: false },
467
- color: { type: "string", required: false },
468
- order: { type: "number", required: false },
469
- isFavorite: { type: "boolean", required: false },
470
- },
585
+ description: "Update a label; only the fields given change",
586
+ inputSchema: labelUpdate,
471
587
  async execute(input, ctx) {
472
588
  const { id, ...fields } = input;
473
- const body = {};
474
- if (fields.name)
475
- body.name = fields.name;
476
- if (fields.color)
477
- body.color = fields.color;
478
- if (fields.order)
479
- body.order = fields.order;
480
- if (fields.isFavorite !== undefined)
481
- body.is_favorite = fields.isFavorite;
482
- return api(ctx, "POST", `/labels/${pathSegment(id)}`, body);
589
+ return api(ctx, "POST", `labels/${pathSegment(id)}`, {
590
+ body: wire(fields, labelNames),
591
+ });
483
592
  },
484
593
  });
485
594
  rl.registerAction("label.delete", {
486
595
  access: "write",
487
596
  description: "Delete a label",
488
- inputSchema: { id: { type: "string", required: true } },
597
+ inputSchema: byId,
489
598
  async execute(input, ctx) {
490
- await api(ctx, "DELETE", `/labels/${pathSegment(input.id)}`);
491
- return { success: true };
599
+ const { id } = input;
600
+ return act(ctx, "DELETE", `labels/${pathSegment(id)}`);
492
601
  },
493
602
  });
494
603
  }