@quire-io/quire-cli 0.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.
Files changed (94) hide show
  1. package/CHANGES.md +33 -0
  2. package/LICENSE +15 -0
  3. package/README.md +69 -0
  4. package/dist/cli.js +209 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/chat.js +200 -0
  7. package/dist/commands/chat.js.map +1 -0
  8. package/dist/commands/colors.js +34 -0
  9. package/dist/commands/colors.js.map +1 -0
  10. package/dist/commands/comment.js +128 -0
  11. package/dist/commands/comment.js.map +1 -0
  12. package/dist/commands/doc.js +139 -0
  13. package/dist/commands/doc.js.map +1 -0
  14. package/dist/commands/insight.js +234 -0
  15. package/dist/commands/insight.js.map +1 -0
  16. package/dist/commands/login.js +78 -0
  17. package/dist/commands/login.js.map +1 -0
  18. package/dist/commands/logout.js +25 -0
  19. package/dist/commands/logout.js.map +1 -0
  20. package/dist/commands/mine.js +61 -0
  21. package/dist/commands/mine.js.map +1 -0
  22. package/dist/commands/notify.js +25 -0
  23. package/dist/commands/notify.js.map +1 -0
  24. package/dist/commands/org.js +99 -0
  25. package/dist/commands/org.js.map +1 -0
  26. package/dist/commands/partner.js +36 -0
  27. package/dist/commands/partner.js.map +1 -0
  28. package/dist/commands/project.js +346 -0
  29. package/dist/commands/project.js.map +1 -0
  30. package/dist/commands/resolve.js +61 -0
  31. package/dist/commands/resolve.js.map +1 -0
  32. package/dist/commands/status.js +130 -0
  33. package/dist/commands/status.js.map +1 -0
  34. package/dist/commands/sublist.js +143 -0
  35. package/dist/commands/sublist.js.map +1 -0
  36. package/dist/commands/tag.js +108 -0
  37. package/dist/commands/tag.js.map +1 -0
  38. package/dist/commands/task.js +767 -0
  39. package/dist/commands/task.js.map +1 -0
  40. package/dist/commands/undo.js +64 -0
  41. package/dist/commands/undo.js.map +1 -0
  42. package/dist/commands/user.js +24 -0
  43. package/dist/commands/user.js.map +1 -0
  44. package/dist/commands/whoami.js +34 -0
  45. package/dist/commands/whoami.js.map +1 -0
  46. package/dist/config.js +37 -0
  47. package/dist/config.js.map +1 -0
  48. package/dist/credentials.js +28 -0
  49. package/dist/credentials.js.map +1 -0
  50. package/dist/errors.js +56 -0
  51. package/dist/errors.js.map +1 -0
  52. package/dist/log.js +37 -0
  53. package/dist/log.js.map +1 -0
  54. package/dist/oauth/config.js +22 -0
  55. package/dist/oauth/config.js.map +1 -0
  56. package/dist/oauth/loopback.js +101 -0
  57. package/dist/oauth/loopback.js.map +1 -0
  58. package/dist/oauth/pkce.js +16 -0
  59. package/dist/oauth/pkce.js.map +1 -0
  60. package/dist/options.js +2 -0
  61. package/dist/options.js.map +1 -0
  62. package/dist/output/colors.js +77 -0
  63. package/dist/output/colors.js.map +1 -0
  64. package/dist/output/columns.js +22 -0
  65. package/dist/output/columns.js.map +1 -0
  66. package/dist/output/keyvalue.js +18 -0
  67. package/dist/output/keyvalue.js.map +1 -0
  68. package/dist/output/render.js +30 -0
  69. package/dist/output/render.js.map +1 -0
  70. package/dist/output/table.js +35 -0
  71. package/dist/output/table.js.map +1 -0
  72. package/dist/quire-client.js +74 -0
  73. package/dist/quire-client.js.map +1 -0
  74. package/dist/util/attach-input.js +65 -0
  75. package/dist/util/attach-input.js.map +1 -0
  76. package/dist/util/bulk-input.js +80 -0
  77. package/dist/util/bulk-input.js.map +1 -0
  78. package/dist/util/confirm.js +31 -0
  79. package/dist/util/confirm.js.map +1 -0
  80. package/dist/util/field-flags.js +52 -0
  81. package/dist/util/field-flags.js.map +1 -0
  82. package/dist/util/open-url.js +39 -0
  83. package/dist/util/open-url.js.map +1 -0
  84. package/dist/util/recurrence.js +53 -0
  85. package/dist/util/recurrence.js.map +1 -0
  86. package/dist/util/retry-after.js +56 -0
  87. package/dist/util/retry-after.js.map +1 -0
  88. package/dist/util/task-id.js +34 -0
  89. package/dist/util/task-id.js.map +1 -0
  90. package/dist/util/text-input.js +26 -0
  91. package/dist/util/text-input.js.map +1 -0
  92. package/dist/version.js +19 -0
  93. package/dist/version.js.map +1 -0
  94. package/package.json +62 -0
@@ -0,0 +1,767 @@
1
+ import { ValidationError } from "../errors.js";
2
+ import { TASK_GET_FIELDS, TASK_LIST_COLUMNS } from "../output/columns.js";
3
+ import { renderList, renderObject } from "../output/render.js";
4
+ import { printTable } from "../output/table.js";
5
+ import { createQuireClient } from "../quire-client.js";
6
+ import { resolveAttachInput } from "../util/attach-input.js";
7
+ import { readBulkItems, readBulkRefs } from "../util/bulk-input.js";
8
+ import { confirmDestructive } from "../util/confirm.js";
9
+ import { parseRecurrence } from "../util/recurrence.js";
10
+ import { resolveTaskOid } from "../util/task-id.js";
11
+ const ATTACHMENT_FIELDS = [
12
+ { label: "Name", get: (a) => a.name },
13
+ { label: "URL", get: (a) => a.url },
14
+ { label: "Length", get: (a) => String(a.length) },
15
+ { label: "Created at", get: (a) => a.createdAt },
16
+ ];
17
+ /** Adds the four shared `--recurrence-*` options to a command. */
18
+ function addRecurrenceOptions(cmd) {
19
+ return cmd
20
+ .option("--recurrence-freq <freq>", "Recurrence: daily | weekly | monthly | yearly")
21
+ .option("--recurrence-interval <n>", "Recurrence: positive integer (every N freq-units)")
22
+ .option("--recurrence-byweekday <days>", "Recurrence: comma-separated day numbers 0-6 (0=Sun)")
23
+ .option("--recurrence-until <date>", "Recurrence: ISO 8601 / YYYY-MM-DD end date");
24
+ }
25
+ const APPROVAL_FIELDS = [
26
+ { label: "State", get: (a) => a.state },
27
+ { label: "Category", get: (a) => a.category || "(default)" },
28
+ { label: "Requester", get: (a) => a.requesterRef?.id ?? a.requester },
29
+ { label: "Approver", get: (a) => a.approverRef?.id ?? a.approver },
30
+ ];
31
+ const TIMELOG_COLUMNS = [
32
+ { header: "START", get: (t) => t.start },
33
+ { header: "END", get: (t) => t.end },
34
+ { header: "USER", get: (t) => t.user.name ?? t.user.id ?? t.user.oid },
35
+ { header: "BILLABLE", get: (t) => (t.billable === true ? "yes" : "") },
36
+ { header: "NOTE", get: (t) => (t.note ?? "").replace(/\s+/g, " ").trim() },
37
+ ];
38
+ /**
39
+ * Bulk endpoints can return mixed shapes: full `QuireTask` (with --return
40
+ * full / when the server happens to send it), `{oid, id?}` compact rows,
41
+ * or `null` for items that failed individually inside an atomic call. One
42
+ * column set covers all three; absent fields render as empty cells.
43
+ */
44
+ function renderBulkResults(results, root) {
45
+ if (root.json === true) {
46
+ process.stdout.write(`${JSON.stringify(results)}\n`);
47
+ return;
48
+ }
49
+ if (root.quiet === true) {
50
+ for (const r of results) {
51
+ const oid = r?.oid;
52
+ process.stdout.write(`${oid ?? ""}\n`);
53
+ }
54
+ return;
55
+ }
56
+ const rows = results;
57
+ printTable(rows, [
58
+ { header: "ID", get: (r) => (r?.id !== undefined ? `#${r.id}` : "") },
59
+ { header: "NAME", get: (r) => (r ? (r.nameText ?? r.name ?? "") : "") },
60
+ { header: "STATUS", get: (r) => r?.status?.name ?? "" },
61
+ { header: "OID", get: (r) => (r ? r.oid : "(failed)") },
62
+ ]);
63
+ }
64
+ function parseLimit(input) {
65
+ if (input === undefined)
66
+ return undefined;
67
+ if (input === "no")
68
+ return "no";
69
+ const n = Number.parseInt(input, 10);
70
+ if (!Number.isInteger(n) || n <= 0) {
71
+ throw new ValidationError(`--limit must be a positive integer or 'no'; got "${input}"`);
72
+ }
73
+ return n;
74
+ }
75
+ export function registerTaskCommand(program) {
76
+ const task = program.command("task").description("Quire tasks.");
77
+ task
78
+ .command("list <project>")
79
+ .description("List tasks in a project. <project> = OID, slug, or URL.")
80
+ .option("--limit <n>", "Page size; positive integer or 'no' for unlimited.")
81
+ .option("--cursor <token>", "Cursor token from a previous page (Apr 2026 cursor pagination).")
82
+ .action(async (project, cmdOpts) => {
83
+ const root = program.opts();
84
+ const client = createQuireClient({ profile: root.profile });
85
+ const oid = await client.resolveProjectOid(project);
86
+ const tasks = await client.listTasks(oid, {
87
+ limit: parseLimit(cmdOpts.limit),
88
+ cursor: cmdOpts.cursor,
89
+ });
90
+ renderList(tasks, root, { columns: TASK_LIST_COLUMNS, toId: (t) => t.oid });
91
+ });
92
+ task
93
+ .command("get <id>")
94
+ .description("Show task details. <id> = OID, project-slug/#408, or full URL.")
95
+ .action(async (id) => {
96
+ const root = program.opts();
97
+ const client = createQuireClient({ profile: root.profile });
98
+ const oid = await resolveTaskOid(client, id);
99
+ const t = await client.getTask(oid);
100
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
101
+ });
102
+ task
103
+ .command("tree <id>")
104
+ .description("Render the recursive subtree under a task.")
105
+ .option("--depth <n>", "Tree depth; positive integer (default 3) or 'full'.", "3")
106
+ .action(async (id, cmdOpts) => {
107
+ const root = program.opts();
108
+ const client = createQuireClient({ profile: root.profile });
109
+ const oid = await resolveTaskOid(client, id);
110
+ const depth = cmdOpts.depth === "full" ? "full" : Number.parseInt(cmdOpts.depth, 10);
111
+ if (depth !== "full" && (!Number.isInteger(depth) || depth <= 0)) {
112
+ throw new ValidationError(`--depth must be a positive integer or 'full'; got "${cmdOpts.depth}"`);
113
+ }
114
+ const tree = await client.listTaskTree(oid, { depth });
115
+ if (root.json === true) {
116
+ process.stdout.write(`${JSON.stringify(tree)}\n`);
117
+ return;
118
+ }
119
+ if (root.quiet === true) {
120
+ const visit = (nodes) => {
121
+ for (const node of nodes) {
122
+ process.stdout.write(`${node.oid}\n`);
123
+ if (node.tasks)
124
+ visit(node.tasks);
125
+ }
126
+ };
127
+ visit(tree);
128
+ return;
129
+ }
130
+ const printNodes = (nodes, level) => {
131
+ for (const node of nodes) {
132
+ const name = node.nameText ?? node.name;
133
+ const cropped = node.cropped ? " …(cropped)" : "";
134
+ process.stdout.write(`${" ".repeat(level)}#${node.id} ${name}${cropped}\n`);
135
+ if (node.tasks)
136
+ printNodes(node.tasks, level + 1);
137
+ }
138
+ };
139
+ printNodes(tree, 0);
140
+ });
141
+ task
142
+ .command("search <query>")
143
+ .description("Search tasks by text. Scope with --project, --org, or --folder (one is required).")
144
+ .option("--project <id>", "Search within one project.")
145
+ .option("--org <id>", "Search within one organization (no project required).")
146
+ .option("--folder <id>", "Search within one folder OID.")
147
+ .option("--mine", "Restrict to tasks assigned to me.")
148
+ .option("--assignee <user>", "Filter by assignee (OID, id, or email).")
149
+ .option("--tag <tag>", "Filter by tag.")
150
+ .option("--status <status>", "Filter: 'active' / 'completed' / numeric 0-100.")
151
+ .option("--priority <priority>", "Filter: low / medium / high / urgent or -1 / 0 / 1 / 2.")
152
+ .option("--limit <n>", "Page size.")
153
+ .action(async (query, cmdOpts) => {
154
+ const root = program.opts();
155
+ const client = createQuireClient({ profile: root.profile });
156
+ const params = {
157
+ text: query,
158
+ ...(cmdOpts.mine ? { mine: true } : {}),
159
+ ...(cmdOpts.assignee ? { assignee: cmdOpts.assignee } : {}),
160
+ ...(cmdOpts.tag ? { tag: cmdOpts.tag } : {}),
161
+ ...(cmdOpts.status ? { status: cmdOpts.status } : {}),
162
+ ...(cmdOpts.priority ? { priority: cmdOpts.priority } : {}),
163
+ ...(cmdOpts.limit ? { limit: parseLimit(cmdOpts.limit) } : {}),
164
+ };
165
+ let tasks;
166
+ if (cmdOpts.project) {
167
+ const oid = await client.resolveProjectOid(cmdOpts.project);
168
+ tasks = await client.searchTasks(oid, params);
169
+ }
170
+ else if (cmdOpts.org) {
171
+ const oid = await client.resolveOrgOid(cmdOpts.org);
172
+ tasks = await client.searchTasksInOrganization(oid, params);
173
+ }
174
+ else if (cmdOpts.folder) {
175
+ tasks = await client.searchTasksInFolder(cmdOpts.folder, params);
176
+ }
177
+ else {
178
+ throw new ValidationError("`task search` requires one of --project, --org, or --folder.");
179
+ }
180
+ renderList(tasks, root, { columns: TASK_LIST_COLUMNS, toId: (t) => t.oid });
181
+ });
182
+ task
183
+ .command("subtasks <id>")
184
+ .description("List a task's direct subtasks.")
185
+ .option("--limit <n>", "Page size.")
186
+ .option("--cursor <token>", "Cursor from a previous page.")
187
+ .action(async (id, cmdOpts) => {
188
+ const root = program.opts();
189
+ const client = createQuireClient({ profile: root.profile });
190
+ const oid = await resolveTaskOid(client, id);
191
+ const subs = await client.listSubtasks(oid, {
192
+ limit: parseLimit(cmdOpts.limit),
193
+ cursor: cmdOpts.cursor,
194
+ });
195
+ renderList(subs, root, { columns: TASK_LIST_COLUMNS, toId: (t) => t.oid });
196
+ });
197
+ task
198
+ .command("comments <id>")
199
+ .description("List a task's comments.")
200
+ .action(async (id) => {
201
+ const root = program.opts();
202
+ const client = createQuireClient({ profile: root.profile });
203
+ const oid = await resolveTaskOid(client, id);
204
+ const comments = await client.getTaskComments(oid);
205
+ renderList(comments, root, {
206
+ columns: [
207
+ {
208
+ header: "WHEN",
209
+ get: (c) => c.createdAt ?? "",
210
+ },
211
+ { header: "WHO", get: (c) => c.createdBy?.name ?? "" },
212
+ {
213
+ header: "TEXT",
214
+ get: (c) => (c.descriptionText ?? "").replace(/\s+/g, " ").trim(),
215
+ },
216
+ { header: "OID", get: (c) => c.oid },
217
+ ],
218
+ toId: (c) => c.oid,
219
+ });
220
+ });
221
+ // -------- Write commands (Phase 5.1) --------
222
+ // Accumulator for repeated CLI flags (e.g. `--tag a --tag b` → ["a","b"]).
223
+ const append = (val, prev) => [...(prev ?? []), val];
224
+ function parseCustomFields(pairs) {
225
+ if (!pairs?.length)
226
+ return undefined;
227
+ const result = {};
228
+ for (const pair of pairs) {
229
+ const eq = pair.indexOf("=");
230
+ if (eq <= 0) {
231
+ throw new ValidationError(`Invalid --custom-field "${pair}". Expected key=value.`);
232
+ }
233
+ result[pair.slice(0, eq)] = pair.slice(eq + 1);
234
+ }
235
+ return result;
236
+ }
237
+ function parsePosition(input, allowed) {
238
+ if (input === undefined)
239
+ return undefined;
240
+ if (!allowed.includes(input)) {
241
+ throw new ValidationError(`--position must be one of ${allowed.join(", ")}; got "${input}"`);
242
+ }
243
+ return input;
244
+ }
245
+ addRecurrenceOptions(task
246
+ .command("create <project>")
247
+ .description("Create a task in a project. Combine with --parent or --sibling+--position to nest / position.")
248
+ .requiredOption("--name <name>", "Task name (required)")
249
+ .option("--description <text>", "Task description")
250
+ .option("--priority <priority>", "low|medium|high|urgent")
251
+ .option("--due <date>", "Due date (YYYY-MM-DD or ISO 8601)")
252
+ .option("--start <date>", "Start date")
253
+ .option("--assignee <user>", "Assignee (OID, id, or email); repeat for multiple", append, [])
254
+ .option("--tag <tag>", "Tag; repeat for multiple", append, [])
255
+ .option("--parent <id>", "Create as a subtask of this parent task")
256
+ .option("--sibling <id>", "Create relative to this sibling task (use with --position)")
257
+ .option("--position <pos>", "Used with --sibling: 'before' or 'after'"))
258
+ .action(async (project, cmdOpts) => {
259
+ const root = program.opts();
260
+ const client = createQuireClient({ profile: root.profile });
261
+ if (cmdOpts.parent !== undefined && cmdOpts.sibling !== undefined) {
262
+ throw new ValidationError("Cannot combine --parent and --sibling.");
263
+ }
264
+ const recurrence = parseRecurrence(cmdOpts);
265
+ const body = {
266
+ name: cmdOpts.name,
267
+ ...(cmdOpts.description !== undefined ? { description: cmdOpts.description } : {}),
268
+ ...(cmdOpts.priority !== undefined ? { priority: cmdOpts.priority } : {}),
269
+ ...(cmdOpts.due !== undefined ? { due: cmdOpts.due } : {}),
270
+ ...(cmdOpts.start !== undefined ? { start: cmdOpts.start } : {}),
271
+ ...((cmdOpts.assignee?.length ?? 0) > 0 ? { assignees: cmdOpts.assignee } : {}),
272
+ ...((cmdOpts.tag?.length ?? 0) > 0 ? { tags: cmdOpts.tag } : {}),
273
+ ...(recurrence !== undefined ? { recurrence } : {}),
274
+ };
275
+ let created;
276
+ if (cmdOpts.parent !== undefined) {
277
+ const parentOid = await resolveTaskOid(client, cmdOpts.parent);
278
+ created = await client.createSubtask(parentOid, body);
279
+ }
280
+ else if (cmdOpts.sibling !== undefined) {
281
+ const pos = parsePosition(cmdOpts.position, ["before", "after"]);
282
+ if (pos !== "before" && pos !== "after") {
283
+ throw new ValidationError("--sibling requires --position before|after.");
284
+ }
285
+ const siblingOid = await resolveTaskOid(client, cmdOpts.sibling);
286
+ created = await client.createTaskRelative(siblingOid, body, pos);
287
+ }
288
+ else {
289
+ const projectOid = await client.resolveProjectOid(project);
290
+ created = await client.createTask(projectOid, body);
291
+ }
292
+ renderObject(created, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
293
+ });
294
+ addRecurrenceOptions(task
295
+ .command("subtask <parent-id>")
296
+ .description("Create a subtask of an existing task. Alias for `task create --parent`.")
297
+ .requiredOption("--name <name>", "Task name (required)")
298
+ .option("--description <text>")
299
+ .option("--priority <priority>", "low|medium|high|urgent")
300
+ .option("--due <date>")
301
+ .option("--start <date>")
302
+ .option("--assignee <user>", "Repeat for multiple", append, [])
303
+ .option("--tag <tag>", "Repeat for multiple", append, []))
304
+ .action(async (parentId, cmdOpts) => {
305
+ const root = program.opts();
306
+ const client = createQuireClient({ profile: root.profile });
307
+ const parentOid = await resolveTaskOid(client, parentId);
308
+ const recurrence = parseRecurrence(cmdOpts);
309
+ const t = await client.createSubtask(parentOid, {
310
+ name: cmdOpts.name,
311
+ ...(cmdOpts.description !== undefined ? { description: cmdOpts.description } : {}),
312
+ ...(cmdOpts.priority !== undefined ? { priority: cmdOpts.priority } : {}),
313
+ ...(cmdOpts.due !== undefined ? { due: cmdOpts.due } : {}),
314
+ ...(cmdOpts.start !== undefined ? { start: cmdOpts.start } : {}),
315
+ ...((cmdOpts.assignee?.length ?? 0) > 0 ? { assignees: cmdOpts.assignee } : {}),
316
+ ...((cmdOpts.tag?.length ?? 0) > 0 ? { tags: cmdOpts.tag } : {}),
317
+ ...(recurrence !== undefined ? { recurrence } : {}),
318
+ });
319
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
320
+ });
321
+ addRecurrenceOptions(task
322
+ .command("update <id>")
323
+ .description("Update task fields. Pass any subset of flags.")
324
+ .option("--name <name>")
325
+ .option("--description <text>")
326
+ .option("--status <value>", "Numeric 0-100 (see `quire status list`)")
327
+ .option("--priority <priority>", "low|medium|high|urgent")
328
+ .option("--due <date>")
329
+ .option("--start <date>")
330
+ .option("--add-tag <tag>", "Repeat for multiple", append, [])
331
+ .option("--remove-tag <tag>", "Repeat for multiple", append, [])
332
+ .option("--add-assignee <user>", "Repeat for multiple", append, [])
333
+ .option("--remove-assignee <user>", "Repeat for multiple", append, [])
334
+ .option("--add-successor <id>", "Repeat for multiple", append, [])
335
+ .option("--remove-successor <id>", "Repeat for multiple", append, [])
336
+ .option("--custom-field <kv>", "key=value; repeat for multiple", append, []))
337
+ .action(async (id, cmdOpts) => {
338
+ const root = program.opts();
339
+ const client = createQuireClient({ profile: root.profile });
340
+ const oid = await resolveTaskOid(client, id);
341
+ let status;
342
+ if (cmdOpts.status !== undefined) {
343
+ const n = Number.parseInt(cmdOpts.status, 10);
344
+ if (!Number.isInteger(n) || n < 0 || n > 100) {
345
+ throw new ValidationError(`--status must be an integer 0-100; got "${cmdOpts.status}"`);
346
+ }
347
+ status = n;
348
+ }
349
+ const recurrence = parseRecurrence(cmdOpts);
350
+ const body = {
351
+ ...(cmdOpts.name !== undefined ? { name: cmdOpts.name } : {}),
352
+ ...(cmdOpts.description !== undefined ? { description: cmdOpts.description } : {}),
353
+ ...(status !== undefined ? { status } : {}),
354
+ ...(cmdOpts.priority !== undefined ? { priority: cmdOpts.priority } : {}),
355
+ ...(cmdOpts.due !== undefined ? { due: cmdOpts.due } : {}),
356
+ ...(cmdOpts.start !== undefined ? { start: cmdOpts.start } : {}),
357
+ ...((cmdOpts.addTag?.length ?? 0) > 0 ? { addTags: cmdOpts.addTag } : {}),
358
+ ...((cmdOpts.removeTag?.length ?? 0) > 0 ? { removeTags: cmdOpts.removeTag } : {}),
359
+ ...((cmdOpts.addAssignee?.length ?? 0) > 0 ? { addAssignees: cmdOpts.addAssignee } : {}),
360
+ ...((cmdOpts.removeAssignee?.length ?? 0) > 0 ? { removeAssignees: cmdOpts.removeAssignee } : {}),
361
+ ...((cmdOpts.addSuccessor?.length ?? 0) > 0 ? { addSuccessors: cmdOpts.addSuccessor } : {}),
362
+ ...((cmdOpts.removeSuccessor?.length ?? 0) > 0 ? { removeSuccessors: cmdOpts.removeSuccessor } : {}),
363
+ ...((cmdOpts.customField?.length ?? 0) > 0 ? { customFields: parseCustomFields(cmdOpts.customField) } : {}),
364
+ ...(recurrence !== undefined ? { recurrence } : {}),
365
+ };
366
+ const t = await client.updateTask(oid, body);
367
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
368
+ });
369
+ task
370
+ .command("complete <id>")
371
+ .description("Mark a task complete.")
372
+ .action(async (id) => {
373
+ const root = program.opts();
374
+ const client = createQuireClient({ profile: root.profile });
375
+ const oid = await resolveTaskOid(client, id);
376
+ const t = await client.completeTask(oid);
377
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
378
+ });
379
+ task
380
+ .command("uncomplete <id>")
381
+ .description("Re-open a completed task (sets status to 0 / active).")
382
+ .action(async (id) => {
383
+ const root = program.opts();
384
+ const client = createQuireClient({ profile: root.profile });
385
+ const oid = await resolveTaskOid(client, id);
386
+ const t = await client.updateTask(oid, { status: 0 });
387
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
388
+ });
389
+ task
390
+ .command("move <id>")
391
+ .description("Re-parent a task within the same project.")
392
+ .option("--to <id>", "New parent task ID, or 'root' to move under the project root")
393
+ .option("--position <pos>", "'parent', 'before', or 'after'")
394
+ .action(async (id, cmdOpts) => {
395
+ const root = program.opts();
396
+ const client = createQuireClient({ profile: root.profile });
397
+ const oid = await resolveTaskOid(client, id);
398
+ let parentOid;
399
+ if (cmdOpts.to !== undefined && cmdOpts.to !== "root") {
400
+ parentOid = await resolveTaskOid(client, cmdOpts.to);
401
+ }
402
+ const position = parsePosition(cmdOpts.position, ["parent", "before", "after"]);
403
+ const t = await client.moveTask(oid, parentOid, position);
404
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
405
+ });
406
+ task
407
+ .command("transfer <id>")
408
+ .description("Transfer a task to a different project (cross-project move).")
409
+ .requiredOption("--to <project>", "Target project (OID, slug, or URL)")
410
+ .option("--task <id>", "Anchor task within the target project (use with --position)")
411
+ .option("--position <pos>", "'parent', 'before', or 'after' relative to --task")
412
+ .option("--keep-tags", "Preserve tags during transfer")
413
+ .option("--keep-status", "Preserve status during transfer")
414
+ .option("--keep-custom-fields", "Preserve custom fields during transfer")
415
+ .option("--invite", "Invite the assignees to the target project")
416
+ .action(async (id, cmdOpts) => {
417
+ const root = program.opts();
418
+ const client = createQuireClient({ profile: root.profile });
419
+ const oid = await resolveTaskOid(client, id);
420
+ const projectOid = await client.resolveProjectOid(cmdOpts.to);
421
+ const taskAnchor = cmdOpts.task !== undefined ? await resolveTaskOid(client, cmdOpts.task) : undefined;
422
+ const position = parsePosition(cmdOpts.position, ["parent", "before", "after"]);
423
+ const t = await client.transferTask(oid, {
424
+ project: projectOid,
425
+ ...(taskAnchor !== undefined ? { task: taskAnchor } : {}),
426
+ ...(position !== undefined ? { position } : {}),
427
+ ...(cmdOpts.invite === true ? { invite: true } : {}),
428
+ ...(cmdOpts.keepTags === true ? { tag: true } : {}),
429
+ ...(cmdOpts.keepStatus === true ? { status: true } : {}),
430
+ ...(cmdOpts.keepCustomFields === true ? { customField: true } : {}),
431
+ });
432
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
433
+ });
434
+ task
435
+ .command("dates <id>")
436
+ .description("Set or clear task start / due dates. Pass 'null' to clear a date.")
437
+ .option("--start <date>", "Start date or 'null' to clear")
438
+ .option("--due <date>", "Due date or 'null' to clear")
439
+ .action(async (id, cmdOpts) => {
440
+ const root = program.opts();
441
+ const client = createQuireClient({ profile: root.profile });
442
+ const oid = await resolveTaskOid(client, id);
443
+ const body = {};
444
+ if (cmdOpts.start !== undefined)
445
+ body.start = cmdOpts.start === "null" ? null : cmdOpts.start;
446
+ if (cmdOpts.due !== undefined)
447
+ body.due = cmdOpts.due === "null" ? null : cmdOpts.due;
448
+ if (body.start === undefined && body.due === undefined) {
449
+ throw new ValidationError("`task dates` requires at least one of --start or --due.");
450
+ }
451
+ const t = await client.updateTask(oid, body);
452
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
453
+ });
454
+ task
455
+ .command("peekaboo <id>")
456
+ .description("Hide (peekaboo) a task. --reshow-at <ISO 8601> sets a re-show time.")
457
+ .option("--reshow-at <iso8601>", "Reshow at this ISO 8601 timestamp (omit to hide indefinitely)")
458
+ .action(async (id, cmdOpts) => {
459
+ const root = program.opts();
460
+ const client = createQuireClient({ profile: root.profile });
461
+ const oid = await resolveTaskOid(client, id);
462
+ let peekaboo = true;
463
+ if (cmdOpts.reshowAt !== undefined) {
464
+ const ms = Date.parse(cmdOpts.reshowAt);
465
+ if (Number.isNaN(ms)) {
466
+ throw new ValidationError(`--reshow-at must be ISO 8601 (e.g. 2026-05-08T12:00:00Z); got "${cmdOpts.reshowAt}"`);
467
+ }
468
+ peekaboo = ms;
469
+ }
470
+ const t = await client.updateTask(oid, { peekaboo });
471
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
472
+ });
473
+ task
474
+ .command("delete <id>")
475
+ .description("Delete a task. Prompts for confirmation unless --yes is set.")
476
+ .action(async (id) => {
477
+ const root = program.opts();
478
+ const client = createQuireClient({ profile: root.profile });
479
+ const oid = await resolveTaskOid(client, id);
480
+ await confirmDestructive({
481
+ question: `Delete task ${oid}? Run \`quire task undo-remove ${oid}\` to restore.`,
482
+ yes: root.yes,
483
+ });
484
+ await client.deleteTask(oid);
485
+ if (root.json === true) {
486
+ process.stdout.write(`${JSON.stringify({ oid, deleted: true })}\n`);
487
+ }
488
+ else if (root.quiet === true) {
489
+ process.stdout.write(`${oid}\n`);
490
+ }
491
+ else {
492
+ process.stderr.write(`Deleted task ${oid}.\n`);
493
+ }
494
+ });
495
+ task
496
+ .command("undo-remove <id>")
497
+ .description("Restore a deleted task. Requires the task OID — slug/#N forms cannot address deleted tasks.")
498
+ .action(async (id) => {
499
+ const root = program.opts();
500
+ const client = createQuireClient({ profile: root.profile });
501
+ const t = await client.undoRemoveTask(id);
502
+ renderObject(t, root, { fields: TASK_GET_FIELDS, toId: (t) => t.oid });
503
+ });
504
+ task
505
+ .command("attach <id> <file>")
506
+ .description("Attach a file to a task. <file> = path on disk, or '-' to read bytes from stdin (then --filename is required).")
507
+ .option("--filename <name>", "Server-side filename (defaults to the basename of <file>; required when <file> = '-')")
508
+ .option("--content-type <mime>", "Content-Type header (defaults to extension-based guess; falls back to application/octet-stream)")
509
+ .action(async (id, file, cmdOpts) => {
510
+ const root = program.opts();
511
+ const client = createQuireClient({ profile: root.profile });
512
+ const oid = await resolveTaskOid(client, id);
513
+ const input = await resolveAttachInput(file, cmdOpts);
514
+ const a = await client.attachTaskFile(oid, input.filename, input.bytes, input.contentType);
515
+ renderObject(a, root, { fields: ATTACHMENT_FIELDS, toId: (a) => a.url });
516
+ });
517
+ // -------- Bulk operations (Phase 5.3 slice A; Apr 27 2026 endpoints) --------
518
+ task
519
+ .command("bulk-create <project>")
520
+ .description("Create many tasks atomically (max 300/call). --from-file accepts a JSON array of task objects; '-' reads stdin.")
521
+ .requiredOption("--from-file <file>", "JSON array of task objects (or '-' for stdin)")
522
+ .action(async (project, cmdOpts) => {
523
+ const root = program.opts();
524
+ const client = createQuireClient({ profile: root.profile });
525
+ const projectOid = await client.resolveProjectOid(project);
526
+ const items = await readBulkItems(cmdOpts.fromFile);
527
+ const results = await client.bulkCreateTasks(projectOid, items);
528
+ renderBulkResults(results, root);
529
+ });
530
+ task
531
+ .command("bulk-subtasks <parent>")
532
+ .description("Create many subtasks under one parent atomically (max 300/call). <parent> = OID, slug/#N, or URL.")
533
+ .requiredOption("--from-file <file>", "JSON array of task objects (or '-' for stdin)")
534
+ .option("--position <pos>", "Insert position: parent | before | after (default: server's submit-order)")
535
+ .action(async (parent, cmdOpts) => {
536
+ const root = program.opts();
537
+ const client = createQuireClient({ profile: root.profile });
538
+ if (cmdOpts.position !== undefined && !["parent", "before", "after"].includes(cmdOpts.position)) {
539
+ throw new ValidationError("--position must be one of: parent, before, after");
540
+ }
541
+ const parentOid = await resolveTaskOid(client, parent);
542
+ const items = await readBulkItems(cmdOpts.fromFile);
543
+ const results = await client.bulkCreateSubtasks(parentOid, items, {
544
+ ...(cmdOpts.position !== undefined ? { position: cmdOpts.position } : {}),
545
+ });
546
+ renderBulkResults(results, root);
547
+ });
548
+ task
549
+ .command("bulk-update <project>")
550
+ .description("Update many tasks atomically. Each item must include `oid`.")
551
+ .requiredOption("--from-file <file>", "JSON array of update objects (or '-' for stdin)")
552
+ .action(async (project, cmdOpts) => {
553
+ const root = program.opts();
554
+ const client = createQuireClient({ profile: root.profile });
555
+ const projectOid = await client.resolveProjectOid(project);
556
+ const items = await readBulkItems(cmdOpts.fromFile);
557
+ const results = await client.bulkUpdateTasks(projectOid, items);
558
+ renderBulkResults(results, root);
559
+ });
560
+ task
561
+ .command("bulk-delete <project>")
562
+ .description("Delete many tasks. Refs are OIDs or numeric IDs; one per line, blank / '#'-comment lines OK, JSON array also accepted.")
563
+ .requiredOption("--from-file <file>", "Ref list (one per line) or JSON array (or '-' for stdin)")
564
+ .action(async (project, cmdOpts) => {
565
+ const root = program.opts();
566
+ const client = createQuireClient({ profile: root.profile });
567
+ const projectOid = await client.resolveProjectOid(project);
568
+ const refs = await readBulkRefs(cmdOpts.fromFile);
569
+ await confirmDestructive({
570
+ question: `Delete ${refs.length} task(s) from project ${projectOid}? Restore individually with \`quire task undo-remove\`.`,
571
+ yes: root.yes,
572
+ });
573
+ const results = await client.bulkRemoveTasks(projectOid, refs);
574
+ renderBulkResults(results, root);
575
+ });
576
+ task
577
+ .command("bulk-move <project>")
578
+ .description("Re-parent many tasks within the same project.")
579
+ .requiredOption("--from-file <file>", "Ref list (one per line) or JSON array (or '-' for stdin)")
580
+ .requiredOption("--to <id>", "New parent task OID, or 'root' to move under the project root")
581
+ .option("--position <pos>", "'parent', 'before', or 'after'")
582
+ .action(async (project, cmdOpts) => {
583
+ const root = program.opts();
584
+ const client = createQuireClient({ profile: root.profile });
585
+ const projectOid = await client.resolveProjectOid(project);
586
+ const refs = await readBulkRefs(cmdOpts.fromFile);
587
+ const parent = cmdOpts.to === "root" ? "root" : await resolveTaskOid(client, cmdOpts.to);
588
+ const position = parsePosition(cmdOpts.position, ["parent", "before", "after"]);
589
+ const results = await client.bulkMoveTasks(projectOid, refs, {
590
+ task: parent,
591
+ ...(position !== undefined ? { position } : {}),
592
+ });
593
+ renderBulkResults(results, root);
594
+ });
595
+ task
596
+ .command("bulk-transfer <project>")
597
+ .description("Transfer many tasks from <project> to another project.")
598
+ .requiredOption("--from-file <file>", "Ref list (one per line) or JSON array (or '-' for stdin)")
599
+ .requiredOption("--to <project>", "Target project (OID, slug, or URL)")
600
+ .option("--task <id>", "Anchor task in the target project")
601
+ .option("--position <pos>", "'parent', 'before', or 'after'")
602
+ .option("--keep-tags", "Preserve tags during transfer")
603
+ .option("--keep-status", "Preserve status during transfer")
604
+ .option("--keep-custom-fields", "Preserve custom fields during transfer")
605
+ .option("--invite", "Invite the assignees to the target project")
606
+ .action(async (project, cmdOpts) => {
607
+ const root = program.opts();
608
+ const client = createQuireClient({ profile: root.profile });
609
+ const sourceOid = await client.resolveProjectOid(project);
610
+ const targetOid = await client.resolveProjectOid(cmdOpts.to);
611
+ const refs = await readBulkRefs(cmdOpts.fromFile);
612
+ const taskAnchor = cmdOpts.task !== undefined ? await resolveTaskOid(client, cmdOpts.task) : undefined;
613
+ const position = parsePosition(cmdOpts.position, ["parent", "before", "after"]);
614
+ const results = await client.bulkTransferTasks(sourceOid, refs, {
615
+ project: targetOid,
616
+ ...(taskAnchor !== undefined ? { task: taskAnchor } : {}),
617
+ ...(position !== undefined ? { position } : {}),
618
+ ...(cmdOpts.invite === true ? { invite: true } : {}),
619
+ ...(cmdOpts.keepTags === true ? { tag: true } : {}),
620
+ ...(cmdOpts.keepStatus === true ? { status: true } : {}),
621
+ ...(cmdOpts.keepCustomFields === true ? { customField: true } : {}),
622
+ });
623
+ renderBulkResults(results, root);
624
+ });
625
+ task
626
+ .command("bulk-approve <project>")
627
+ .description("Apply an approval state to many tasks at once.")
628
+ .requiredOption("--from-file <file>", "Ref list (one per line) or JSON array (or '-' for stdin)")
629
+ .requiredOption("--state <state>", "'request' | 'approve' | 'reject' | 'change'")
630
+ .option("--category <id>", "Approval category (only used when --state request)")
631
+ .action(async (project, cmdOpts) => {
632
+ const root = program.opts();
633
+ const client = createQuireClient({ profile: root.profile });
634
+ const projectOid = await client.resolveProjectOid(project);
635
+ const refs = await readBulkRefs(cmdOpts.fromFile);
636
+ const validStates = ["request", "approve", "reject", "change"];
637
+ if (!validStates.includes(cmdOpts.state)) {
638
+ throw new ValidationError(`--state must be one of ${validStates.join(", ")}; got "${cmdOpts.state}"`);
639
+ }
640
+ const results = await client.bulkApproveTasks(projectOid, refs, {
641
+ state: cmdOpts.state,
642
+ ...(cmdOpts.category !== undefined ? { category: cmdOpts.category } : {}),
643
+ });
644
+ renderBulkResults(results, root);
645
+ });
646
+ // -------- Approval (Phase 5.3 slice B) --------
647
+ task
648
+ .command("approve <id>")
649
+ .description("Set a task's approval state.")
650
+ .requiredOption("--state <state>", "request | approve | reject | change")
651
+ .option("--category <id>", "Approval category id (only meaningful for --state request)")
652
+ .action(async (id, cmdOpts) => {
653
+ const root = program.opts();
654
+ const client = createQuireClient({ profile: root.profile });
655
+ const oid = await resolveTaskOid(client, id);
656
+ const validStates = ["request", "approve", "reject", "change"];
657
+ if (!validStates.includes(cmdOpts.state)) {
658
+ throw new ValidationError(`--state must be one of ${validStates.join(", ")}; got "${cmdOpts.state}"`);
659
+ }
660
+ const a = await client.approveTask(oid, {
661
+ state: cmdOpts.state,
662
+ ...(cmdOpts.category !== undefined ? { category: cmdOpts.category } : {}),
663
+ });
664
+ renderObject(a, root, { fields: APPROVAL_FIELDS, toId: () => oid });
665
+ });
666
+ task
667
+ .command("revoke-approval <id>")
668
+ .description("Revoke any approval state on a task.")
669
+ .action(async (id) => {
670
+ const root = program.opts();
671
+ const client = createQuireClient({ profile: root.profile });
672
+ const oid = await resolveTaskOid(client, id);
673
+ await client.revokeTaskApproval(oid);
674
+ if (root.json === true) {
675
+ process.stdout.write(`${JSON.stringify({ oid, revoked: true })}\n`);
676
+ }
677
+ else if (root.quiet === true) {
678
+ process.stdout.write(`${oid}\n`);
679
+ }
680
+ else {
681
+ process.stderr.write(`Revoked approval on task ${oid}.\n`);
682
+ }
683
+ });
684
+ // -------- Timelogs (Phase 5.3 slice B; Apr 27 2026 endpoints) --------
685
+ const timelog = task.command("timelog").description("Quire task time tracking.");
686
+ timelog
687
+ .command("add <task-id>")
688
+ .description("Add a timelog entry to a task.")
689
+ .requiredOption("--start <iso8601>", "Start timestamp (ISO 8601)")
690
+ .requiredOption("--end <iso8601>", "End timestamp (ISO 8601)")
691
+ .option("--user <user>", "Log against another user (OID, id, or email; default: self)")
692
+ .option("--billable", "Mark as billable")
693
+ .option("--note <text>", "Optional note")
694
+ .action(async (taskId, cmdOpts) => {
695
+ const root = program.opts();
696
+ const client = createQuireClient({ profile: root.profile });
697
+ const oid = await resolveTaskOid(client, taskId);
698
+ const logs = await client.addTaskTimelog(oid, {
699
+ start: cmdOpts.start,
700
+ end: cmdOpts.end,
701
+ ...(cmdOpts.user !== undefined ? { user: cmdOpts.user } : {}),
702
+ ...(cmdOpts.billable === true ? { billable: true } : {}),
703
+ ...(cmdOpts.note !== undefined ? { note: cmdOpts.note } : {}),
704
+ });
705
+ renderList(logs, root, { columns: TIMELOG_COLUMNS, toId: (l) => `${l.start}/${l.end}` });
706
+ });
707
+ timelog
708
+ .command("update <task-id>")
709
+ .description("Update a timelog entry. Use --start / --end / --user to identify the row; --new-* and --note / --billable to change it.")
710
+ .requiredOption("--start <iso8601>", "Existing start timestamp")
711
+ .requiredOption("--end <iso8601>", "Existing end timestamp")
712
+ .option("--user <user>", "User on the existing row (when not self)")
713
+ .option("--new-start <iso8601>", "Replace the start timestamp")
714
+ .option("--new-end <iso8601>", "Replace the end timestamp")
715
+ .option("--new-user <user>", "Replace the user")
716
+ .option("--billable", "Mark billable")
717
+ .option("--no-billable", "Clear the billable flag")
718
+ .option("--note <text>", "Replace the note (pass an empty string to clear)")
719
+ .action(async (taskId, cmdOpts) => {
720
+ const root = program.opts();
721
+ const client = createQuireClient({ profile: root.profile });
722
+ const oid = await resolveTaskOid(client, taskId);
723
+ const target = { start: cmdOpts.start, end: cmdOpts.end };
724
+ if (cmdOpts.user !== undefined)
725
+ target.user = cmdOpts.user;
726
+ const body = {};
727
+ if (cmdOpts.newStart !== undefined)
728
+ body.start = cmdOpts.newStart;
729
+ if (cmdOpts.newEnd !== undefined)
730
+ body.end = cmdOpts.newEnd;
731
+ if (cmdOpts.newUser !== undefined)
732
+ body.user = cmdOpts.newUser;
733
+ if (cmdOpts.billable === true)
734
+ body.billable = true;
735
+ else if (cmdOpts.billable === false)
736
+ body.billable = false;
737
+ if (cmdOpts.note !== undefined)
738
+ body.note = cmdOpts.note === "" ? null : cmdOpts.note;
739
+ if (Object.keys(body).length === 0) {
740
+ throw new ValidationError("`task timelog update` requires at least one of --new-start / --new-end / --new-user / --billable / --no-billable / --note.");
741
+ }
742
+ const logs = await client.updateTaskTimelog(oid, target, body);
743
+ renderList(logs, root, { columns: TIMELOG_COLUMNS, toId: (l) => `${l.start}/${l.end}` });
744
+ });
745
+ timelog
746
+ .command("remove <task-id>")
747
+ .description("Remove a timelog entry. Identify the row via --start / --end (and --user if not self).")
748
+ .requiredOption("--start <iso8601>", "Existing start timestamp")
749
+ .requiredOption("--end <iso8601>", "Existing end timestamp")
750
+ .option("--user <user>", "User on the row (when not self)")
751
+ .action(async (taskId, cmdOpts) => {
752
+ const root = program.opts();
753
+ const client = createQuireClient({ profile: root.profile });
754
+ const oid = await resolveTaskOid(client, taskId);
755
+ await confirmDestructive({
756
+ question: `Remove timelog ${cmdOpts.start} → ${cmdOpts.end} from task ${oid}?`,
757
+ yes: root.yes,
758
+ });
759
+ const logs = await client.removeTaskTimelog(oid, {
760
+ start: cmdOpts.start,
761
+ end: cmdOpts.end,
762
+ ...(cmdOpts.user !== undefined ? { user: cmdOpts.user } : {}),
763
+ });
764
+ renderList(logs, root, { columns: TIMELOG_COLUMNS, toId: (l) => `${l.start}/${l.end}` });
765
+ });
766
+ }
767
+ //# sourceMappingURL=task.js.map