@sudosapient/cli 0.1.0 → 0.2.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.
@@ -0,0 +1,1202 @@
1
+ /* sudo-cli's commands beyond the everyday ones in cli.js: one table of noun → verb → handler, so
2
+ every endpoint of /api/v1 has a command and `sudo-cli help <noun>` can list them. Each handler
3
+ gets `x` (built in cli.js): the API client, parsed options, positionals after the verb, and
4
+ output helpers. It returns an exit code (0 when it returns nothing). */
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { ApiError, seg } from '@sudosapient/client';
8
+
9
+ export const STATUSES = ['backlog', 'todo', 'progress', 'review', 'done'];
10
+ export const PRIORITIES = ['urgent', 'high', 'medium', 'low', 'none'];
11
+ const PROJECT_STATUSES = ['planning', 'active', 'risk', 'hold', 'complete'];
12
+ const RECUR = ['Daily', 'Weekly', 'Every 2 weeks', 'Monthly'];
13
+ const PERMS = { view: 'Can view', comment: 'Can comment', edit: 'Can edit', full: 'Full access' };
14
+ const ROLES = ['Owner', 'Admin', 'Member', 'Guest'];
15
+ const REACTIONS = { thumbsup: '👍', '+1': '👍', tada: '🎉', heart: '❤️', eyes: '👀', rocket: '🚀', check: '✅' };
16
+ const FILTER_FIELDS = ['status', 'assignee', 'priority', 'due', 'project', 'labels', 'created', 'updated'];
17
+ const TYPES = {
18
+ pdf: 'application/pdf',
19
+ png: 'image/png',
20
+ jpg: 'image/jpeg',
21
+ jpeg: 'image/jpeg',
22
+ gif: 'image/gif',
23
+ webp: 'image/webp',
24
+ svg: 'image/svg+xml',
25
+ txt: 'text/plain',
26
+ md: 'text/markdown',
27
+ csv: 'text/csv',
28
+ json: 'application/json',
29
+ html: 'text/html',
30
+ zip: 'application/zip',
31
+ doc: 'application/msword',
32
+ docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
33
+ xls: 'application/vnd.ms-excel',
34
+ xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
35
+ };
36
+ export const contentType = file => TYPES[path.extname(file).slice(1).toLowerCase()] || 'application/octet-stream';
37
+
38
+ /* A file name the server sent, made safe to create in a directory: its last path part, without
39
+ control characters, never "", "." or "..". */
40
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
41
+ const CONTROL = /[\x00-\x1f\x7f]/g;
42
+ export const safeFileName = name =>
43
+ path
44
+ .basename(
45
+ String(name || '')
46
+ .replace(CONTROL, '')
47
+ .replace(/\\/g, '/'),
48
+ )
49
+ .replace(/^\.+$/, '') || 'download';
50
+
51
+ const list = v =>
52
+ v == null
53
+ ? []
54
+ : []
55
+ .concat(v)
56
+ .flatMap(s => String(s).split(','))
57
+ .map(s => s.trim())
58
+ .filter(Boolean);
59
+ const when = ms => (ms ? new Date(ms).toISOString().slice(0, 16).replace('T', ' ') : '');
60
+ const onOff = (k, v) => {
61
+ if (/^(on|true|yes)$/i.test(v)) return true;
62
+ if (/^(off|false|no)$/i.test(v)) return false;
63
+ return k === 'weekStart' && /^\d+$/.test(v) ? Number(v) : v;
64
+ };
65
+ /* key=value pairs for `prefs set`. */
66
+ const pairs = (x, args, usage) => {
67
+ if (!args.length) x.usage(usage);
68
+ return Object.fromEntries(
69
+ args.map(a => {
70
+ const m = /^([A-Za-z_]+)=(.*)$/.exec(a);
71
+ if (!m) x.usage(`Expected key=value, not “${a}”. ${usage}`);
72
+ return [m[1], onOff(m[1], m[2])];
73
+ }),
74
+ );
75
+ };
76
+ const notFound = what => new ApiError(404, { error: `No ${what}.`, code: 'not_found' });
77
+
78
+ /* ---------- shared pieces ---------- */
79
+ const nonEmpty = (x, body, what) => {
80
+ if (!Object.keys(body).length) x.usage(`Nothing to change. ${what}`);
81
+ return body;
82
+ };
83
+ const choice = (x, v, opts, what) => {
84
+ if (v == null) return undefined;
85
+ if (!opts.includes(v)) x.usage(`${what} is one of ${opts.join(', ')}.`);
86
+ return v;
87
+ };
88
+ const perm = (x, v) => {
89
+ if (v == null) return undefined;
90
+ const p =
91
+ PERMS[
92
+ v
93
+ .toLowerCase()
94
+ .replace(/^can /, '')
95
+ .replace(/ access$/, '')
96
+ ] || Object.values(PERMS).find(p => p.toLowerCase() === v.toLowerCase());
97
+ if (!p) x.usage('Permission is one of view, comment, edit, full (or "Can view", …).');
98
+ return p;
99
+ };
100
+ const repeat = (x, v) => {
101
+ if (v == null) return undefined;
102
+ if (/^(none|off)$/i.test(v)) return null;
103
+ const r = RECUR.find(r => r.toLowerCase() === v.toLowerCase().replace(/-/g, ' '));
104
+ if (!r) x.usage(`Repeat is one of ${RECUR.join(', ')}, or none.`);
105
+ return r;
106
+ };
107
+ const orNone = v => (v == null ? undefined : /^none$/i.test(v) ? null : v);
108
+ async function teamId(x, a, ref) {
109
+ const teams = (await a.get('/teams')).data;
110
+ const t = teams.find(t => t.id === ref) || teams.find(t => t.name.toLowerCase() === String(ref).toLowerCase());
111
+ if (!t) throw notFound(`team matches “${ref}”`);
112
+ return t;
113
+ }
114
+ async function botOf(x, a, ref) {
115
+ const bots = (await a.get('/bots')).data;
116
+ const b = bots.find(b => b.id === ref) || bots.find(b => b.name.toLowerCase() === String(ref).toLowerCase());
117
+ if (!b) throw notFound(`bot matches “${ref}”`);
118
+ return b;
119
+ }
120
+ const filterOf = (x, s) => {
121
+ const m = /^([a-z]+)(!=|=)(.*)$/.exec(s);
122
+ if (!m || !FILTER_FIELDS.includes(m[1])) x.usage(`A filter is field=value[,value] or field!=value, with field one of ${FILTER_FIELDS.join(', ')}.`);
123
+ return { field: m[1], op: m[2] === '=' ? 'is' : 'not', values: list(m[3]) };
124
+ };
125
+
126
+ /* Task fields from options, for edit (and the subset new uses in cli.js). */
127
+ function taskBody(x) {
128
+ const o = x.o;
129
+ const b = {};
130
+ if (o.title != null) b.title = o.title;
131
+ if (o.status != null) b.status = choice(x, o.status, STATUSES, 'Status');
132
+ if (o.priority != null) b.priority = choice(x, o.priority, PRIORITIES, 'Priority');
133
+ if (o.assignee != null) b.assignee = orNone(o.assignee);
134
+ if (o.due != null) b.due = x.date(o.due);
135
+ if (o.start != null) b.start = x.date(o.start);
136
+ if (o.label != null) b.labels = o.label.some(l => /^none$/i.test(l)) ? [] : list(o.label);
137
+ if (o.repeat != null) b.recurrence = repeat(x, o.repeat);
138
+ if (o.estimate != null) b.estimate = orNone(o.estimate);
139
+ if (o.project != null) b.project = o.project;
140
+ if (o.desc != null) b.description = o.desc;
141
+ if (o.depends != null) b.dependsOn = /^none$/i.test(o.depends) ? [] : list(o.depends);
142
+ if (o.order != null) {
143
+ b.order = Number(o.order);
144
+ if (!Number.isFinite(b.order)) x.usage('--order is a number.');
145
+ }
146
+ return b;
147
+ }
148
+ function projectBody(x) {
149
+ const o = x.o;
150
+ const b = {};
151
+ if (o.name != null) b.name = o.name;
152
+ if (o.desc != null) b.description = o.desc;
153
+ if (o.status != null) b.status = choice(x, o.status, PROJECT_STATUSES, 'Project status');
154
+ if (o.lead != null) b.lead = orNone(o.lead);
155
+ if (o.team != null) b.team = orNone(o.team);
156
+ if (o.start != null) b.start = x.date(o.start);
157
+ if (o.due != null) b.due = x.date(o.due);
158
+ if (o.icon != null) b.icon = o.icon;
159
+ if (o.color != null) b.color = o.color;
160
+ if (o.private && o.public) x.usage('Pick one of --private and --public.');
161
+ if (o.private) b.private = true;
162
+ if (o.public) b.private = false;
163
+ if (o.milestone != null)
164
+ b.milestones = o.milestone.some(m => /^none$/i.test(m))
165
+ ? []
166
+ : o.milestone.map(m => {
167
+ const [name, date] = m.split(/=(.*)/s);
168
+ return { name: name.trim(), date: date ? x.date(date) : null };
169
+ });
170
+ return b;
171
+ }
172
+ const taskLine = (x, t) => `${x.bold(t.key)} ${t.title}`;
173
+ const projLine = (x, p) => `${x.bold(x.pad(p.key, 8))} ${p.name}${p.archived ? x.dim(' · archived') : ''}`;
174
+
175
+ /* ---------- the table ---------- */
176
+ /* noun → { about, verbs: { verb: { usage, run } }, default? } */
177
+ export const NOUNS = {
178
+ tasks: {
179
+ about: 'Tasks: list, create, edit, complete, assign, archive, delete',
180
+ verbs: {
181
+ list: { usage: 'tasks [list] [--mine] [-p <project>] [-s <status>] [--all] [--archived] [-n <limit>]' },
182
+ new: { usage: 'tasks new "<title>" -p <project> [--due <date>] [-a <who>] [--priority <p>] [-s <status>] [-d <text>]' },
183
+ show: { usage: 'tasks show <KEY> details, subtasks and comments (with their ids)' },
184
+ done: { usage: 'tasks done <KEY>...' },
185
+ assign: { usage: 'tasks assign <KEY> <who|none>' },
186
+ edit: {
187
+ usage:
188
+ 'tasks edit <KEY> [--title <t>] [-s <status>] [--priority <p>] [-a <who|none>] [--due <date|none>] [--start <date|none>]\n' +
189
+ ' [--label <a,b> (repeatable; replaces; none clears)] [--repeat <Daily|Weekly|"Every 2 weeks"|Monthly|none>]\n' +
190
+ ' [--estimate <e|none>] [-p <move to project>] [-d <description>] [--depends <KEY,…|none>] [--order <n>]',
191
+ async run(x) {
192
+ const k = x.need(x.args[0], 'Which task? sudo-cli tasks edit WEB-12 --title "…"');
193
+ const t = await x.api().patch(`/tasks/${seg(k)}`, nonEmpty(x, taskBody(x), 'Pass a field, e.g. --title or --due.'));
194
+ x.emit(t, () => x.print(`Updated ${taskLine(x, t)}`));
195
+ },
196
+ },
197
+ rm: {
198
+ usage: 'tasks rm <KEY>... delete (with comments); undo with tasks undelete <id> within 10 minutes',
199
+ async run(x) {
200
+ x.need(x.args.length, 'Which task? sudo-cli tasks rm WEB-12');
201
+ const out = [];
202
+ for (const k of x.args) out.push({ key: k.toUpperCase(), ...(await x.api().del(`/tasks/${seg(k)}`)) });
203
+ x.emit(out.length === 1 ? out[0] : out, () => {
204
+ for (const d of out) x.print(`Deleted ${x.bold(d.key)}. Undo within 10 minutes: sudo-cli tasks undelete ${d.id}`);
205
+ });
206
+ },
207
+ },
208
+ undelete: {
209
+ usage: 'tasks undelete <id> put back a task you deleted (with its key and comments)',
210
+ async run(x) {
211
+ const t = await x.api().post(`/tasks/${seg(x.need(x.args[0], 'Which task id? It was printed by tasks rm.'))}/undelete`);
212
+ x.emit(t, () => x.print(`Restored ${taskLine(x, t)}`));
213
+ },
214
+ },
215
+ archive: {
216
+ usage: 'tasks archive <KEY>...',
217
+ run: x =>
218
+ each(
219
+ x,
220
+ k => x.api().post(`/tasks/${seg(k)}/archive`),
221
+ t => `Archived ${taskLine(x, t)}`,
222
+ ),
223
+ },
224
+ restore: {
225
+ usage: 'tasks restore <KEY>...',
226
+ run: x =>
227
+ each(
228
+ x,
229
+ k => x.api().post(`/tasks/${seg(k)}/restore`),
230
+ t => `Restored ${taskLine(x, t)}`,
231
+ ),
232
+ },
233
+ dup: {
234
+ usage: 'tasks dup <KEY> copy it (title + " (copy)")',
235
+ async run(x) {
236
+ const t = await x.api().post(`/tasks/${seg(x.need(x.args[0], 'Which task? sudo-cli tasks dup WEB-12'))}/duplicate`);
237
+ x.emit(t, () => x.print(`Created ${taskLine(x, t)}`));
238
+ },
239
+ },
240
+ fav: {
241
+ usage: 'tasks fav <KEY>...',
242
+ run: x =>
243
+ each(
244
+ x,
245
+ k => x.api().put(`/tasks/${seg(k)}/favorite`),
246
+ t => `Starred ${taskLine(x, t)}`,
247
+ ),
248
+ },
249
+ unfav: {
250
+ usage: 'tasks unfav <KEY>...',
251
+ run: x =>
252
+ each(
253
+ x,
254
+ k => x.api().del(`/tasks/${seg(k)}/favorite`),
255
+ t => `Unstarred ${taskLine(x, t)}`,
256
+ ),
257
+ },
258
+ },
259
+ },
260
+
261
+ subtask: {
262
+ about: 'Subtasks of a task (ids are shown by tasks show)',
263
+ verbs: {
264
+ add: {
265
+ usage: 'subtask add <KEY> "<title>" [-a <who>] [--due <date>]',
266
+ async run(x) {
267
+ const [k, ...rest] = x.args;
268
+ const title = rest.join(' ').trim();
269
+ x.need(k && title, 'sudo-cli subtask add WEB-12 "Write the tests"');
270
+ const body = { title, ...(x.o.assignee ? { assignee: orNone(x.o.assignee) } : {}), ...(x.o.due ? { due: x.date(x.o.due) } : {}) };
271
+ const t = await x.api().post(`/tasks/${seg(k)}/subtasks`, body);
272
+ const s = t.subtasks.at(-1);
273
+ x.emit(t, () => x.print(`Added “${s.title}” to ${x.bold(t.key)} ${x.dim(`(${s.id})`)}`));
274
+ },
275
+ },
276
+ done: { usage: 'subtask done <KEY> <id>', run: x => subPatch(x, { done: true }, 'Done') },
277
+ undo: { usage: 'subtask undo <KEY> <id> mark not done', run: x => subPatch(x, { done: false }, 'Reopened') },
278
+ edit: {
279
+ usage: 'subtask edit <KEY> <id> [--title <t>] [-a <who|none>] [--due <date|none>]',
280
+ run: x => {
281
+ const b = {};
282
+ if (x.o.title != null) b.title = x.o.title;
283
+ if (x.o.assignee != null) b.assignee = orNone(x.o.assignee);
284
+ if (x.o.due != null) b.due = x.date(x.o.due);
285
+ return subPatch(x, nonEmpty(x, b, 'Pass --title, -a or --due.'), 'Updated');
286
+ },
287
+ },
288
+ rm: {
289
+ usage: 'subtask rm <KEY> <id>',
290
+ async run(x) {
291
+ const [k, id] = x.args;
292
+ x.need(k && id, 'sudo-cli subtask rm WEB-12 <subtask id>');
293
+ const t = await x.api().del(`/tasks/${seg(k)}/subtasks/${seg(id)}`);
294
+ x.emit(t, () => x.print(`Removed a subtask from ${x.bold(t.key)}`));
295
+ },
296
+ },
297
+ promote: {
298
+ usage: 'subtask promote <KEY> <id> turn it into a task of its own',
299
+ async run(x) {
300
+ const [k, id] = x.args;
301
+ x.need(k && id, 'sudo-cli subtask promote WEB-12 <subtask id>');
302
+ const t = await x.api().post(`/tasks/${seg(k)}/subtasks/${seg(id)}/promote`);
303
+ x.emit(t, () => x.print(`Converted to ${taskLine(x, t)}`));
304
+ },
305
+ },
306
+ },
307
+ },
308
+
309
+ comment: {
310
+ about: 'Comments: add, edit, delete, react',
311
+ verbs: {
312
+ add: { usage: 'comment <KEY> "<text>" @Name mentions notify' },
313
+ edit: {
314
+ usage: 'comment edit <id> "<text>" your own comments',
315
+ async run(x) {
316
+ const [id, ...rest] = x.args;
317
+ const text = rest.join(' ').trim();
318
+ x.need(id && text, 'sudo-cli comment edit <comment id> "New text"');
319
+ const c = await x.api().patch(`/comments/${seg(id)}`, { text });
320
+ x.emit(c, () => x.print('Comment updated'));
321
+ },
322
+ },
323
+ rm: {
324
+ usage: 'comment rm <id> undo with comment undelete <id> within 10 minutes',
325
+ async run(x) {
326
+ const id = x.need(x.args[0], 'sudo-cli comment rm <comment id>');
327
+ const d = await x.api().del(`/comments/${seg(id)}`);
328
+ x.emit(d, () => x.print(`Deleted the comment. Undo within 10 minutes: sudo-cli comment undelete ${d.id}`));
329
+ },
330
+ },
331
+ undelete: {
332
+ usage: 'comment undelete <id>',
333
+ async run(x) {
334
+ const c = await x.api().post(`/comments/${seg(x.need(x.args[0], 'sudo-cli comment undelete <comment id>'))}/undelete`);
335
+ x.emit(c, () => x.print('Comment restored'));
336
+ },
337
+ },
338
+ react: { usage: 'react <comment id> <emoji> 👍 🎉 ❤️ 👀 🚀 ✅ (or thumbsup, tada, heart, eyes, rocket, check)' },
339
+ unreact: { usage: 'unreact <comment id> <emoji>' },
340
+ },
341
+ },
342
+
343
+ projects: {
344
+ about: 'Projects: create, edit, share, archive, duplicate, delete',
345
+ verbs: {
346
+ list: { usage: 'projects [list] [--archived]' },
347
+ show: {
348
+ usage: 'projects show <KEY>',
349
+ async run(x) {
350
+ const a = x.api();
351
+ const [p, names] = await Promise.all([a.get(`/projects/${seg(x.need(x.args[0], 'Which project? sudo-cli projects show WEB'))}`), x.names(a)]);
352
+ x.emit(p, () => {
353
+ x.print(`${x.bold(p.key)} ${p.name}`);
354
+ x.print(
355
+ x.dim(
356
+ [
357
+ p.status,
358
+ p.lead ? `lead ${names.get(p.lead) || '?'}` : 'no lead',
359
+ p.private ? 'private' : 'open to the workspace',
360
+ `you: ${p.access}`,
361
+ p.archived ? 'archived' : '',
362
+ ]
363
+ .filter(Boolean)
364
+ .join(' · '),
365
+ ),
366
+ );
367
+ if (p.description) x.print('\n' + p.description);
368
+ x.print(`\n${p.openTasks} open · ${p.doneTasks} done${p.start ? ` · ${p.start}` : ''}${p.due ? ` → ${p.due}` : ''}`);
369
+ if (p.members.length)
370
+ x.print('\nPeople: ' + p.members.map(m => `${names.get(m) || m}${p.permissions[m] ? ` (${p.permissions[m]})` : ''}`).join(', '));
371
+ for (const m of p.milestones) x.print(` ◆ ${m.name}${m.date ? ` ${m.date.slice(0, 10)}` : ''}`);
372
+ x.print('\n' + x.dim(p.url));
373
+ });
374
+ },
375
+ },
376
+ new: {
377
+ usage:
378
+ 'projects new "<name>" [--prefix <KEY>] [-d <text>] [-s <status>] [--lead <who>] [--team <team>] [--start <date>] [--due <date>]\n' +
379
+ ' [--private] [--icon <icon>] [--color <colour>] [--milestone "Name=date"]... [--members <who,…>] [--perm <level>]',
380
+ async run(x) {
381
+ const name = x.need(x.args.join(' ').trim(), 'Name the project: sudo-cli projects new "Mobile App"');
382
+ const p = perm(x, x.o.perm);
383
+ const body = {
384
+ ...projectBody(x),
385
+ name,
386
+ ...(x.o.prefix ? { key: x.o.prefix } : {}),
387
+ ...(x.o.members ? { members: list(x.o.members).map(m => ({ member: m, ...(p ? { permission: p } : {}) })) } : {}),
388
+ };
389
+ const r = await x.api().post('/projects', body);
390
+ x.emit(r, () => x.print(`Created ${x.bold(r.key)} ${r.name}\n${x.dim(r.url)}`));
391
+ },
392
+ },
393
+ edit: {
394
+ usage:
395
+ 'projects edit <KEY> [--name <n>] [-d <text>] [-s <status>] [--lead <who|none>] [--team <team|none>] [--start <date|none>]\n' +
396
+ ' [--due <date|none>] [--private | --public] [--icon <i>] [--color <c>] [--milestone "Name=date"]... (replaces; none clears)',
397
+ async run(x) {
398
+ const k = x.need(x.args[0], 'Which project? sudo-cli projects edit WEB --status risk');
399
+ const p = await x.api().patch(`/projects/${seg(k)}`, nonEmpty(x, projectBody(x), 'Pass a field, e.g. --status or --due.'));
400
+ x.emit(p, () => x.print(`Updated ${projLine(x, p)}`));
401
+ },
402
+ },
403
+ archive: {
404
+ usage: 'projects archive <KEY>',
405
+ run: x =>
406
+ one(
407
+ x,
408
+ k => x.api().post(`/projects/${seg(k)}/archive`),
409
+ p => `Archived ${projLine(x, p)}`,
410
+ ),
411
+ },
412
+ restore: {
413
+ usage: 'projects restore <KEY>',
414
+ run: x =>
415
+ one(
416
+ x,
417
+ k => x.api().post(`/projects/${seg(k)}/restore`),
418
+ p => `Restored ${projLine(x, p)}`,
419
+ ),
420
+ },
421
+ dup: {
422
+ usage: 'projects dup <KEY> copy it with its tasks (as To Do)',
423
+ run: x =>
424
+ one(
425
+ x,
426
+ k => x.api().post(`/projects/${seg(k)}/duplicate`),
427
+ p => `Created ${projLine(x, p)}`,
428
+ ),
429
+ },
430
+ rm: {
431
+ usage: 'projects rm <KEY> [--yes] delete it with all its work, for everyone (asks first)',
432
+ async run(x) {
433
+ const k = x.need(x.args[0], 'Which project? sudo-cli projects rm WEB');
434
+ const a = x.api();
435
+ const p = await a
436
+ .get(`/projects/${seg(k)}`)
437
+ .catch(e => (e.status === 404 ? { key: k.toUpperCase(), name: k, openTasks: 0, doneTasks: 0 } : Promise.reject(e)));
438
+ if (
439
+ !(await x.confirm(`Delete ${p.key} ${p.name} and its ${p.openTasks + p.doneTasks} tasks, files and comments for everyone? This can't be undone.`))
440
+ )
441
+ return x.cancelled();
442
+ const d = await a.del(`/projects/${seg(k)}`);
443
+ x.emit(d, () => x.print(`Deleted ${x.bold(p.key)} ${p.name}`));
444
+ },
445
+ },
446
+ share: {
447
+ usage: 'projects share <KEY> <who> [--perm view|comment|edit|full] list someone, or change their permission',
448
+ async run(x) {
449
+ const [k, ...w] = x.args;
450
+ const who = w.join(' ').trim();
451
+ x.need(k && who, 'sudo-cli projects share WEB grace@example.com --perm edit');
452
+ const p = await x.api().put(`/projects/${seg(k)}/members/${seg(who)}`, x.o.perm ? { permission: perm(x, x.o.perm) } : {});
453
+ x.emit(p, () => x.print(`Shared ${x.bold(p.key)} with ${who}${x.o.perm ? ` (${perm(x, x.o.perm)})` : ''}`));
454
+ },
455
+ },
456
+ unshare: {
457
+ usage: 'projects unshare <KEY> <who>',
458
+ async run(x) {
459
+ const [k, ...w] = x.args;
460
+ const who = w.join(' ').trim();
461
+ x.need(k && who, 'sudo-cli projects unshare WEB grace@example.com');
462
+ const p = await x.api().del(`/projects/${seg(k)}/members/${seg(who)}`);
463
+ x.emit(p, () => x.print(`Took ${who} off ${x.bold(p.key)}`));
464
+ },
465
+ },
466
+ 'request-access': {
467
+ usage: 'projects request-access <KEY> ask the lead of a project you can’t open',
468
+ async run(x) {
469
+ const r = await x.api().post(`/projects/${seg(x.need(x.args[0], 'Which project?'))}/request-access`);
470
+ x.emit(r, () => x.print('Asked for access. The lead gets a notification.'));
471
+ },
472
+ },
473
+ fav: {
474
+ usage: 'projects fav <KEY>',
475
+ run: x =>
476
+ one(
477
+ x,
478
+ k => x.api().put(`/projects/${seg(k)}/favorite`),
479
+ p => `Starred ${projLine(x, p)}`,
480
+ ),
481
+ },
482
+ unfav: {
483
+ usage: 'projects unfav <KEY>',
484
+ run: x =>
485
+ one(
486
+ x,
487
+ k => x.api().del(`/projects/${seg(k)}/favorite`),
488
+ p => `Unstarred ${projLine(x, p)}`,
489
+ ),
490
+ },
491
+ order: {
492
+ usage: 'projects order <KEY>... your sidebar order, top first',
493
+ async run(x) {
494
+ x.need(x.args.length, 'sudo-cli projects order WEB OPS SEC');
495
+ const r = await x.api().put('/me/project-order', { projects: x.args });
496
+ x.emit(r, () => x.print('Sidebar order saved'));
497
+ },
498
+ },
499
+ },
500
+ },
501
+
502
+ teams: {
503
+ about: 'Teams',
504
+ default: 'list',
505
+ verbs: {
506
+ list: {
507
+ usage: 'teams [list]',
508
+ async run(x) {
509
+ const a = x.api();
510
+ const [r, names] = await Promise.all([a.get('/teams'), x.names(a)]);
511
+ x.emit(r.data, () => {
512
+ if (!r.data.length) x.print(x.dim('No teams.'));
513
+ for (const t of r.data) x.print(`${x.bold(x.pad(t.name, 20))} ${x.dim(x.pad(t.id, 16))} ${t.members.map(m => names.get(m) || m).join(', ')}`);
514
+ });
515
+ },
516
+ },
517
+ new: {
518
+ usage: 'teams new "<name>" [-d <text>] [--icon <i>] [--color <#RRGGBB>] [--members <who,…>]',
519
+ async run(x) {
520
+ const name = x.need(x.args.join(' ').trim(), 'Name the team: sudo-cli teams new Platform');
521
+ const t = await x.api().post('/teams', { name, ...teamBody(x) });
522
+ x.emit(t, () => x.print(`Created team ${x.bold(t.name)} ${x.dim(`(${t.id})`)}`));
523
+ },
524
+ },
525
+ edit: {
526
+ usage: 'teams edit <team> [--name <n>] [-d <text>] [--icon <i>] [--color <c>] [--members <who,…> (replaces)]',
527
+ async run(x) {
528
+ const a = x.api();
529
+ const t = await teamId(x, a, x.need(x.args.join(' ').trim(), 'Which team? sudo-cli teams edit Platform --members ada,grace'));
530
+ const r = await a.patch(
531
+ `/teams/${seg(t.id)}`,
532
+ nonEmpty(x, { ...(x.o.name ? { name: x.o.name } : {}), ...teamBody(x) }, 'Pass --name, -d, --members, …'),
533
+ );
534
+ x.emit(r, () => x.print(`Updated team ${x.bold(r.name)}`));
535
+ },
536
+ },
537
+ rm: {
538
+ usage: 'teams rm <team> [--yes] its people and projects are kept, with no team',
539
+ async run(x) {
540
+ const a = x.api();
541
+ const t = await teamId(x, a, x.need(x.args.join(' ').trim(), 'Which team?'));
542
+ if (!(await x.confirm(`Delete the team ${t.name}? Its ${t.members.length} people and its projects are kept, with no team.`))) return x.cancelled();
543
+ const d = await a.del(`/teams/${seg(t.id)}`);
544
+ x.emit(d, () => x.print(`Deleted team ${x.bold(t.name)}`));
545
+ },
546
+ },
547
+ },
548
+ },
549
+
550
+ people: {
551
+ about: 'People: invite, resend, roles, teams, titles, remove',
552
+ default: 'list',
553
+ verbs: {
554
+ list: {
555
+ usage: 'people [list]',
556
+ async run(x) {
557
+ const ms = await x.api().all('/members');
558
+ x.emit(ms, () => {
559
+ for (const m of ms)
560
+ x.print(
561
+ `${x.bold(x.pad(m.name, 22))} ${x.pad(m.kind === 'bot' ? '(bot)' : m.email, 30)} ${x.pad(m.role, 7)} ${x.dim([m.title, m.status === 'invited' ? 'invited' : ''].filter(Boolean).join(' · '))}`,
562
+ );
563
+ });
564
+ },
565
+ },
566
+ show: {
567
+ usage: 'people show <who>',
568
+ async run(x) {
569
+ const m = await x.api().get(`/members/${seg(x.need(x.args.join(' ').trim(), 'Who? sudo-cli people show grace@example.com'))}`);
570
+ x.emit(m, () =>
571
+ x.print(
572
+ `${x.bold(m.name)}${m.kind === 'bot' ? ' (bot)' : ` <${m.email}>`} · ${m.role}${m.title ? ` · ${m.title}` : ''}${m.status === 'invited' ? ' · invited' : ''}${x.dim(` (${m.id})`)}`,
573
+ ),
574
+ );
575
+ },
576
+ },
577
+ invite: {
578
+ usage: 'people invite <email> [--name <n>] [--role Member|Guest|Admin] [--team <team>] [--title <t>] (only Owners and Admins invite Admins)',
579
+ async run(x) {
580
+ const email = x.need(x.args[0], 'sudo-cli people invite grace@example.com');
581
+ const body = {
582
+ email,
583
+ ...(x.o.name ? { name: x.o.name } : {}),
584
+ ...(x.o.role ? { role: choice(x, x.o.role, ['Admin', 'Member', 'Guest'], 'Role') } : {}),
585
+ ...(x.o.team ? { team: x.o.team } : {}),
586
+ ...(x.o.title != null ? { title: x.o.title } : {}),
587
+ };
588
+ const r = await x.api().post('/members', body);
589
+ x.emit(r, () =>
590
+ x.print(`Invited ${r.member.name} <${r.member.email}> as ${r.member.role}. They’ll get an email to join.${r.link ? `\n${x.dim(r.link)}` : ''}`),
591
+ );
592
+ },
593
+ },
594
+ resend: {
595
+ usage: 'people resend <who> a fresh invite for someone who hasn’t joined',
596
+ async run(x) {
597
+ const who = x.need(x.args.join(' ').trim(), 'Who? sudo-cli people resend grace@example.com');
598
+ const r = await x.api().post(`/members/${seg(who)}/resend-invite`);
599
+ x.emit(r, () => x.print(`Sent ${who} a new invite`));
600
+ },
601
+ },
602
+ edit: {
603
+ usage: 'people edit <who> [--name <n>] [--title <t>] [--team <team|none>] (others’ name and title: Owners and Admins)',
604
+ async run(x) {
605
+ const who = x.need(x.args.join(' ').trim(), 'Who? sudo-cli people edit grace --team Platform');
606
+ const b = {};
607
+ if (x.o.name != null) b.name = x.o.name;
608
+ if (x.o.title != null) b.title = x.o.title;
609
+ if (x.o.team != null) b.team = orNone(x.o.team);
610
+ const m = await x.api().patch(`/members/${seg(who)}`, nonEmpty(x, b, 'Pass --name, --title or --team.'));
611
+ x.emit(m, () => x.print(`Updated ${m.name}`));
612
+ },
613
+ },
614
+ role: {
615
+ usage: 'people role <who> <Owner|Admin|Member|Guest> (Owners and Admins)',
616
+ async run(x) {
617
+ const role = x.args.at(-1);
618
+ const who = x.args.slice(0, -1).join(' ').trim();
619
+ x.need(who && role, 'sudo-cli people role grace Admin');
620
+ const m = await x.api().patch(`/members/${seg(who)}`, { role: choice(x, role, ROLES, 'Role') });
621
+ x.emit(m, () => x.print(`${m.name} is now ${m.role}`));
622
+ },
623
+ },
624
+ rm: {
625
+ usage: 'people rm <who> [--yes] remove from the workspace (Owners and Admins; asks first)',
626
+ async run(x) {
627
+ const a = x.api();
628
+ const m = await a.get(`/members/${seg(x.need(x.args.join(' ').trim(), 'Who? sudo-cli people rm grace@example.com'))}`);
629
+ if (!(await x.confirm(`Remove ${m.name} from the workspace? Their tasks are unassigned and they’re signed out everywhere.`))) return x.cancelled();
630
+ const d = await a.del(`/members/${seg(m.id)}`);
631
+ x.emit(d, () => x.print(`Removed ${m.name}`));
632
+ },
633
+ },
634
+ },
635
+ },
636
+
637
+ me: {
638
+ about: 'Your profile',
639
+ default: 'show',
640
+ verbs: {
641
+ show: { usage: 'me = whoami' },
642
+ edit: {
643
+ usage: 'me edit [--name <n>] [--title <t>] [--team <team|none>] [--color <#RRGGBB>]',
644
+ async run(x) {
645
+ const b = {};
646
+ if (x.o.name != null) b.name = x.o.name;
647
+ if (x.o.title != null) b.title = x.o.title;
648
+ if (x.o.team != null) b.team = orNone(x.o.team);
649
+ if (x.o.color != null) b.color = x.o.color;
650
+ const m = await x.api().patch('/me', nonEmpty(x, b, 'Pass --name, --title, --team or --color.'));
651
+ x.emit(m, () => x.print(`Updated your profile: ${m.name}${m.title ? ` · ${m.title}` : ''}`));
652
+ },
653
+ },
654
+ },
655
+ },
656
+
657
+ bots: {
658
+ about: 'Bots (Owners and Admins; their keys are made in Settings → API → Bots)',
659
+ default: 'list',
660
+ verbs: {
661
+ list: {
662
+ usage: 'bots [list]',
663
+ async run(x) {
664
+ const r = await x.api().get('/bots');
665
+ x.emit(r.data, () => {
666
+ if (!r.data.length) x.print(x.dim('No bots.'));
667
+ for (const b of r.data) {
668
+ x.print(`${x.bold(x.pad(b.name, 22))} ${x.pad(b.role, 7)} ${x.dim(`${b.projects.length} projects · ${b.keys.length} keys · ${b.id}`)}`);
669
+ for (const k of b.keys) x.print(x.dim(` key ${k.name} ${k.prefix}… ${k.scope} (${k.id})`));
670
+ }
671
+ });
672
+ },
673
+ },
674
+ new: {
675
+ usage: 'bots new "<name>" [--role Member|Guest] [--projects <KEY,…>]',
676
+ async run(x) {
677
+ const name = x.need(x.args.join(' ').trim(), 'Name the bot: sudo-cli bots new "Release Bot"');
678
+ const b = await x.api().post('/bots', { name, ...botBody(x) });
679
+ x.emit(b, () => x.print(`Created bot ${x.bold(b.name)} ${x.dim(`(${b.id})`)}. Make its key in Settings → API → Bots.`));
680
+ },
681
+ },
682
+ edit: {
683
+ usage: 'bots edit <bot> [--name <n>] [--role Member|Guest] [--projects <KEY,…|none> (exactly these)]',
684
+ async run(x) {
685
+ const a = x.api();
686
+ const b = await botOf(x, a, x.need(x.args.join(' ').trim(), 'Which bot?'));
687
+ const r = await a.patch(
688
+ `/bots/${seg(b.id)}`,
689
+ nonEmpty(x, { ...(x.o.name ? { name: x.o.name } : {}), ...botBody(x) }, 'Pass --name, --role or --projects.'),
690
+ );
691
+ x.emit(r, () => x.print(`Updated bot ${x.bold(r.name)}`));
692
+ },
693
+ },
694
+ rm: {
695
+ usage: 'bots rm <bot> [--yes] its keys stop working (asks first)',
696
+ async run(x) {
697
+ const a = x.api();
698
+ const b = await botOf(x, a, x.need(x.args.join(' ').trim(), 'Which bot?'));
699
+ if (!(await x.confirm(`Remove the bot ${b.name}? Its ${b.keys.length} keys stop working.`))) return x.cancelled();
700
+ const d = await a.del(`/bots/${seg(b.id)}`);
701
+ x.emit(d, () => x.print(`Removed bot ${b.name}`));
702
+ },
703
+ },
704
+ 'revoke-key': {
705
+ usage: 'bots revoke-key <bot> <key id>',
706
+ async run(x) {
707
+ const [ref, keyId] = x.args;
708
+ x.need(ref && keyId, 'sudo-cli bots revoke-key "Release Bot" <key id>');
709
+ const a = x.api();
710
+ const b = await botOf(x, a, ref);
711
+ const r = await a.del(`/bots/${seg(b.id)}/keys/${seg(keyId)}`);
712
+ x.emit(r, () => x.print(`Revoked the key; ${r.name} has ${r.keys.length} left`));
713
+ },
714
+ },
715
+ },
716
+ },
717
+
718
+ files: {
719
+ about: 'Files: upload, download, attach, rename, copy, delete',
720
+ default: 'list',
721
+ verbs: {
722
+ list: {
723
+ usage: 'files [list] [-p <project>] [--task <KEY>]',
724
+ async run(x) {
725
+ const fs_ = await x.api().all('/files', { project: x.o.project, task: x.o.task });
726
+ x.emit(fs_, () => {
727
+ if (!fs_.length) x.print(x.dim('No files.'));
728
+ for (const f of fs_) x.print(`${x.dim(x.pad(f.id, 16))} ${x.pad(f.size, 8)} ${f.name}`);
729
+ });
730
+ },
731
+ },
732
+ show: {
733
+ usage: 'files show <id>',
734
+ async run(x) {
735
+ const f = await x.api().get(`/files/${seg(x.need(x.args[0], 'Which file id?'))}`);
736
+ x.emit(f, () => x.print(`${x.bold(f.name)} · ${f.size} · ${f.type}${f.task ? ' · attached to a task' : ''}\n${x.dim(f.id)}`));
737
+ },
738
+ },
739
+ upload: {
740
+ usage: 'files upload <path> (-p <project> | --task <KEY>) [--name <name>]',
741
+ async run(x) {
742
+ const file = x.need(x.args[0], 'sudo-cli files upload ./spec.pdf --task WEB-12');
743
+ x.need(x.o.project || x.o.task, 'Say where it goes: -p <project> or --task <KEY>.');
744
+ let bytes;
745
+ try {
746
+ bytes = fs.readFileSync(path.resolve(x.io.cwd || process.cwd(), file));
747
+ } catch (e) {
748
+ x.usage(`Can't read ${file}: ${e.code === 'ENOENT' ? 'no such file' : e.message}.`);
749
+ }
750
+ const name = x.o.name || path.basename(file);
751
+ const f = await x.api().upload('/files', { project: x.o.project, task: x.o.task, name }, bytes, contentType(name));
752
+ x.emit(f, () => x.print(`Uploaded ${x.bold(f.name)} (${f.size}) ${x.dim(f.id)}`));
753
+ },
754
+ },
755
+ download: {
756
+ usage: 'files download <id> [-o <path>|-] [--force] default: the file’s own name here; never overwrites without --force',
757
+ async run(x) {
758
+ const id = x.need(x.args[0], 'Which file id? sudo-cli files download <id>');
759
+ const a = x.api();
760
+ const got = await a.download(`/files/${seg(id)}/content`, { download: 'true' });
761
+ if (x.o.output === '-') {
762
+ x.io.stdout.write(got.bytes);
763
+ return 0;
764
+ }
765
+ const shown = x.o.output || safeFileName(got.name || (await a.get(`/files/${seg(id)}`)).name);
766
+ const to = path.resolve(x.io.cwd || process.cwd(), shown);
767
+ try {
768
+ fs.writeFileSync(to, got.bytes, { flag: x.o.force ? 'w' : 'wx' });
769
+ } catch (e) {
770
+ if (e.code === 'EEXIST') x.usage(`${shown} already exists. Pass --force to overwrite it, or -o another path.`);
771
+ throw e;
772
+ }
773
+ x.emit({ path: path.resolve(to), bytes: got.bytes.length, type: got.type }, () => x.print(`Saved ${shown} (${got.bytes.length} bytes)`));
774
+ },
775
+ },
776
+ rename: {
777
+ usage: 'files rename <id> "<new name>"',
778
+ async run(x) {
779
+ const [id, ...n] = x.args;
780
+ const name = n.join(' ').trim();
781
+ x.need(id && name, 'sudo-cli files rename <id> "spec v2.pdf"');
782
+ const f = await x.api().patch(`/files/${seg(id)}`, { name });
783
+ x.emit(f, () => x.print(`Renamed to ${f.name}`));
784
+ },
785
+ },
786
+ attach: {
787
+ usage: 'files attach <id> <KEY> attach to a task in its project',
788
+ async run(x) {
789
+ const [id, k] = x.args;
790
+ x.need(id && k, 'sudo-cli files attach <id> WEB-12');
791
+ const f = await x.api().patch(`/files/${seg(id)}`, { task: k });
792
+ x.emit(f, () => x.print(`Attached ${f.name} to ${k.toUpperCase()}`));
793
+ },
794
+ },
795
+ detach: {
796
+ usage: 'files detach <id>',
797
+ async run(x) {
798
+ const f = await x.api().patch(`/files/${seg(x.need(x.args[0], 'Which file id?'))}`, { task: null });
799
+ x.emit(f, () => x.print(`Detached ${f.name}`));
800
+ },
801
+ },
802
+ dup: {
803
+ usage: 'files dup <id>',
804
+ async run(x) {
805
+ const f = await x.api().post(`/files/${seg(x.need(x.args[0], 'Which file id?'))}/duplicate`);
806
+ x.emit(f, () => x.print(`Copied to ${f.name} ${x.dim(f.id)}`));
807
+ },
808
+ },
809
+ rm: {
810
+ usage: 'files rm <id> [--yes] (asks first)',
811
+ async run(x) {
812
+ const a = x.api();
813
+ const f = await a.get(`/files/${seg(x.need(x.args[0], 'Which file id?'))}`);
814
+ if (!(await x.confirm(`Delete ${f.name}${f.task ? ' (and take it off its task)' : ''}?`))) return x.cancelled();
815
+ const d = await a.del(`/files/${seg(f.id)}`);
816
+ x.emit(d, () => x.print(`Deleted ${f.name}`));
817
+ },
818
+ },
819
+ },
820
+ },
821
+
822
+ events: {
823
+ about: 'Calendar events',
824
+ default: 'list',
825
+ verbs: {
826
+ list: {
827
+ usage: 'events [list] [-p <project>] [--from <date>] [--to <date>]',
828
+ async run(x) {
829
+ const es = await x.api().all('/events', { project: x.o.project, from: x.o.from && x.date(x.o.from), to: x.o.to && x.date(x.o.to) });
830
+ x.emit(es, () => {
831
+ if (!es.length) x.print(x.dim('No events.'));
832
+ for (const e of es) x.print(`${x.pad(e.date, 10)} ${x.pad(e.time || 'all day', 7)} ${e.title} ${x.dim(e.id)}`);
833
+ });
834
+ },
835
+ },
836
+ new: {
837
+ usage: 'events new "<title>" --date <date> [--time HH:MM] [-p <project>] (no -p: workspace-wide, Owners and Admins)',
838
+ async run(x) {
839
+ const title = x.need(x.args.join(' ').trim(), 'sudo-cli events new "Launch" --date 2026-12-01 -p WEB');
840
+ const date = x.date(x.need(x.o.date, 'Give a --date.'));
841
+ const e = await x.api().post('/events', { title, date, ...(x.o.time ? { time: x.o.time } : {}), ...(x.o.project ? { project: x.o.project } : {}) });
842
+ x.emit(e, () => x.print(`Added ${e.title} on ${e.date}${e.time ? ` at ${e.time}` : ''} ${x.dim(e.id)}`));
843
+ },
844
+ },
845
+ edit: {
846
+ usage: 'events edit <id> [--title <t>] [--date <date>] [--time HH:MM|none] [-p <project|none>]',
847
+ async run(x) {
848
+ const id = x.need(x.args[0], 'Which event id?');
849
+ const b = {};
850
+ if (x.o.title != null) b.title = x.o.title;
851
+ if (x.o.date != null) b.date = x.date(x.o.date);
852
+ if (x.o.time != null) b.time = /^none$/i.test(x.o.time) ? '' : x.o.time;
853
+ if (x.o.project != null) b.project = orNone(x.o.project);
854
+ const e = await x.api().patch(`/events/${seg(id)}`, nonEmpty(x, b, 'Pass --title, --date, --time or -p.'));
855
+ x.emit(e, () => x.print(`Updated ${e.title} (${e.date})`));
856
+ },
857
+ },
858
+ rm: {
859
+ usage: 'events rm <id>',
860
+ async run(x) {
861
+ const d = await x.api().del(`/events/${seg(x.need(x.args[0], 'Which event id?'))}`);
862
+ x.emit(d, () => x.print('Deleted the event'));
863
+ },
864
+ },
865
+ },
866
+ },
867
+
868
+ views: {
869
+ about: 'Saved views (a project’s extra tabs)',
870
+ default: 'list',
871
+ verbs: {
872
+ list: {
873
+ usage: 'views [list] [-p <project>]',
874
+ async run(x) {
875
+ const vs = await x.api().all('/views', { project: x.o.project });
876
+ x.emit(vs, () => {
877
+ if (!vs.length) x.print(x.dim('No saved views.'));
878
+ for (const v of vs)
879
+ x.print(
880
+ `${x.bold(x.pad(v.name, 20))} ${x.pad(v.type, 6)} ${v.filters.map(f => `${f.field}${f.op === 'is' ? '=' : '!='}${f.values.join(',')}`).join(' ')} ${x.dim(v.id)}`,
881
+ );
882
+ });
883
+ },
884
+ },
885
+ new: {
886
+ usage: 'views new "<name>" -p <project> [--type list|board|table] [--filter field=a,b | field!=a]...',
887
+ async run(x) {
888
+ const name = x.need(x.args.join(' ').trim(), 'sudo-cli views new "Urgent" -p WEB --filter priority=urgent');
889
+ const project = x.need(x.o.project, 'Say which project: -p <project>.');
890
+ const v = await x.api().post('/views', {
891
+ project,
892
+ name,
893
+ ...(x.o.type ? { type: choice(x, x.o.type, ['list', 'board', 'table'], 'Type') } : {}),
894
+ filters: (x.o.filter || []).map(f => filterOf(x, f)),
895
+ });
896
+ x.emit(v, () => x.print(`Saved view ${x.bold(v.name)} ${x.dim(v.id)}`));
897
+ },
898
+ },
899
+ edit: {
900
+ usage: 'views edit <id> [--name <n>] [--type list|board|table] [--filter …]... (replaces; --filter none clears)',
901
+ async run(x) {
902
+ const id = x.need(x.args[0], 'Which view id?');
903
+ const b = {};
904
+ if (x.o.name != null) b.name = x.o.name;
905
+ if (x.o.type != null) b.type = choice(x, x.o.type, ['list', 'board', 'table'], 'Type');
906
+ if (x.o.filter != null) b.filters = x.o.filter.some(f => /^none$/i.test(f)) ? [] : x.o.filter.map(f => filterOf(x, f));
907
+ const v = await x.api().patch(`/views/${seg(id)}`, nonEmpty(x, b, 'Pass --name, --type or --filter.'));
908
+ x.emit(v, () => x.print(`Updated view ${x.bold(v.name)}`));
909
+ },
910
+ },
911
+ rm: {
912
+ usage: 'views rm <id>',
913
+ async run(x) {
914
+ const d = await x.api().del(`/views/${seg(x.need(x.args[0], 'Which view id?'))}`);
915
+ x.emit(d, () => x.print('Deleted the view'));
916
+ },
917
+ },
918
+ },
919
+ },
920
+
921
+ inbox: {
922
+ about: 'Your notifications',
923
+ default: 'list',
924
+ verbs: {
925
+ list: {
926
+ usage: 'inbox [list] [--unread] [-n <limit>]',
927
+ async run(x) {
928
+ const a = x.api();
929
+ const n = Math.min(Math.max(Number(x.o.limit) || 30, 1), 500);
930
+ const [ns, names] = await Promise.all([a.all('/notifications', { unread: x.o.unread ? 'true' : undefined }, n), x.names(a)]);
931
+ x.emit(ns, () => {
932
+ if (!ns.length) x.print(x.dim(x.o.unread ? 'Nothing unread.' : 'Your inbox is empty.'));
933
+ for (const v of ns)
934
+ x.print(
935
+ `${v.read ? ' ' : x.bold('•')} ${x.dim(when(v.at))} ${names.get(v.by) || 'Sudo Sapient'} ${v.text}${v.snippet ? x.dim(` — ${v.snippet}`) : ''} ${x.dim(v.id)}`,
936
+ );
937
+ });
938
+ },
939
+ },
940
+ read: {
941
+ usage: 'inbox read <id>... | inbox read --all',
942
+ async run(x) {
943
+ if (x.o.all) {
944
+ const r = await x.api().post('/notifications/read-all');
945
+ return x.emit(r, () => x.print(`Marked ${r.updated} read`));
946
+ }
947
+ x.need(x.args.length, 'Which notification? sudo-cli inbox read <id> (or --all)');
948
+ return each(
949
+ x,
950
+ id => x.api().patch(`/notifications/${seg(id)}`, { read: true }),
951
+ () => 'Marked read',
952
+ );
953
+ },
954
+ },
955
+ unread: {
956
+ usage: 'inbox unread <id>...',
957
+ run: x =>
958
+ each(
959
+ x,
960
+ id => x.api().patch(`/notifications/${seg(id)}`, { read: false }),
961
+ () => 'Marked unread',
962
+ ),
963
+ },
964
+ },
965
+ },
966
+
967
+ activity: {
968
+ about: 'The workspace’s activity feed',
969
+ default: 'list',
970
+ verbs: {
971
+ list: {
972
+ usage: 'activity [-p <project>] [-n <limit>]',
973
+ async run(x) {
974
+ const a = x.api();
975
+ const n = Math.min(Math.max(Number(x.o.limit) || 30, 1), 500);
976
+ const [as, names, ps] = await Promise.all([
977
+ a.all('/activity', { project: x.o.project }, n),
978
+ x.names(a),
979
+ a.all('/projects', { includeArchived: 'true' }),
980
+ ]);
981
+ const keys = new Map(ps.map(p => [p.id, p.key]));
982
+ x.emit(as, () => {
983
+ if (!as.length) x.print(x.dim('Nothing yet.'));
984
+ for (const e of as)
985
+ x.print(
986
+ `${x.dim(when(e.at))} ${x.pad(keys.get(e.project) || '', 6)} ${names.get(e.by) || '?'}${e.via ? x.dim(` via ${e.via}`) : ''} ${e.verb}${e.extra ? ` ${e.extra}` : ''}`,
987
+ );
988
+ });
989
+ },
990
+ },
991
+ },
992
+ },
993
+
994
+ workspace: {
995
+ about: 'Workspace settings and the invite link (changes: Owners and Admins)',
996
+ default: 'show',
997
+ verbs: {
998
+ show: {
999
+ usage: 'workspace [show]',
1000
+ async run(x) {
1001
+ const w = await x.api().get('/workspace');
1002
+ x.emit(w, () => x.print(`${x.bold(w.name || '(no name)')} · ${w.color}${w.brand ? ' · logo in the sidebar' : ''}`));
1003
+ },
1004
+ },
1005
+ edit: {
1006
+ usage: 'workspace edit [--name <n>] [--color <#RRGGBB>] [--brand on|off] (Owners and Admins)',
1007
+ async run(x) {
1008
+ const b = {};
1009
+ if (x.o.name != null) b.name = x.o.name;
1010
+ if (x.o.color != null) b.color = x.o.color;
1011
+ if (x.o.brand != null) b.brand = onOff('', x.o.brand) === true;
1012
+ const w = await x.api().patch('/workspace', nonEmpty(x, b, 'Pass --name, --color or --brand.'));
1013
+ x.emit(w, () => x.print(`Updated the workspace: ${w.name}`));
1014
+ },
1015
+ },
1016
+ 'invite-link': {
1017
+ usage: 'workspace invite-link a new link to join as a Member; the old one stops working (Owners and Admins)',
1018
+ async run(x) {
1019
+ const r = await x.api().post('/workspace/invite-link');
1020
+ x.emit(r, () => x.print(r.link));
1021
+ },
1022
+ },
1023
+ },
1024
+ },
1025
+
1026
+ prefs: {
1027
+ about: 'Your display preferences',
1028
+ default: 'show',
1029
+ verbs: {
1030
+ show: {
1031
+ usage: 'prefs [show]',
1032
+ async run(x) {
1033
+ const p = await x.api().get('/me/preferences');
1034
+ x.emit(p.preferences, () => {
1035
+ for (const [k, v] of Object.entries(p.preferences)) x.print(`${x.pad(k, 14)} ${v}`);
1036
+ });
1037
+ },
1038
+ },
1039
+ set: {
1040
+ usage: 'prefs set key=value... e.g. theme=dark tz=Europe/Berlin weekStart=1 spell=off',
1041
+ async run(x) {
1042
+ const p = await x.api().patch('/me/preferences', { preferences: pairs(x, x.args, 'sudo-cli prefs set theme=dark') });
1043
+ x.emit(p.preferences, () => x.print('Preferences saved'));
1044
+ },
1045
+ },
1046
+ },
1047
+ },
1048
+
1049
+ 'notify-prefs': {
1050
+ about: 'Your notification preferences',
1051
+ default: 'show',
1052
+ verbs: {
1053
+ show: {
1054
+ usage: 'notify-prefs [show]',
1055
+ async run(x) {
1056
+ const p = await x.api().get('/me/preferences');
1057
+ x.emit(p.notifications, () => {
1058
+ for (const [k, v] of Object.entries(p.notifications)) x.print(`${x.pad(k, 16)} ${v ? 'on' : 'off'}`);
1059
+ });
1060
+ },
1061
+ },
1062
+ set: {
1063
+ usage: 'notify-prefs set key=on|off... e.g. email_comment=on push_due=off',
1064
+ async run(x) {
1065
+ const p = await x.api().patch('/me/preferences', { notifications: pairs(x, x.args, 'sudo-cli notify-prefs set email_comment=on') });
1066
+ x.emit(p.notifications, () => x.print('Notification preferences saved'));
1067
+ },
1068
+ },
1069
+ },
1070
+ },
1071
+
1072
+ favorites: {
1073
+ about: 'What you starred',
1074
+ default: 'list',
1075
+ verbs: {
1076
+ list: {
1077
+ usage: 'favorites (star with tasks fav / projects fav)',
1078
+ async run(x) {
1079
+ const a = x.api();
1080
+ const f = await a.get('/me/favorites');
1081
+ if (x.o.json) return x.json(f);
1082
+ const [ps, ts] = await Promise.all([a.all('/projects', { includeArchived: 'true' }), Promise.all(f.tasks.map(id => a.get(`/tasks/${seg(id)}`)))]);
1083
+ for (const p of ps.filter(p => f.projects.includes(p.id))) x.print(projLine(x, p));
1084
+ for (const t of ts) x.print(taskLine(x, t));
1085
+ if (!f.projects.length && !f.tasks.length) x.print(x.dim('Nothing starred.'));
1086
+ },
1087
+ },
1088
+ },
1089
+ },
1090
+ };
1091
+
1092
+ /* The other names a noun goes by. */
1093
+ export const ALIASES = {
1094
+ task: 'tasks',
1095
+ project: 'projects',
1096
+ subtasks: 'subtask',
1097
+ comments: 'comment',
1098
+ team: 'teams',
1099
+ person: 'people',
1100
+ member: 'people',
1101
+ members: 'people',
1102
+ bot: 'bots',
1103
+ file: 'files',
1104
+ event: 'events',
1105
+ calendar: 'events',
1106
+ view: 'views',
1107
+ notifications: 'inbox',
1108
+ pref: 'prefs',
1109
+ preferences: 'prefs',
1110
+ favourites: 'favorites',
1111
+ favs: 'favorites',
1112
+ };
1113
+
1114
+ export function nounHelp(noun) {
1115
+ const n = NOUNS[noun];
1116
+ return `sudo-cli ${noun}: ${n.about}\n\n${Object.values(n.verbs)
1117
+ .map(v => ' sudo-cli ' + v.usage)
1118
+ .join('\n')}\n\nAll take --json.${noun === 'subtask' || noun === 'tasks' ? ' Dates: today, tomorrow, friday, +3d, 2026-11-20 (none clears).' : ''}`;
1119
+ }
1120
+
1121
+ /* ---------- helpers for handlers ---------- */
1122
+ async function each(x, call, line) {
1123
+ x.need(x.args.length, 'Which one? Give at least one key or id.');
1124
+ const out = [];
1125
+ for (const k of x.args) out.push(await call(k));
1126
+ x.emit(out.length === 1 ? out[0] : out, () => out.forEach(r => x.print(line(r))));
1127
+ }
1128
+ async function one(x, call, line) {
1129
+ const r = await call(x.need(x.args[0], 'Which one? Give a key, e.g. WEB.'));
1130
+ x.emit(r, () => x.print(line(r)));
1131
+ }
1132
+ async function subPatch(x, body, verb) {
1133
+ const [k, id] = x.args;
1134
+ x.need(k && id, `sudo-cli subtask ${verb.toLowerCase()} WEB-12 <subtask id> (ids are in sudo-cli show WEB-12)`);
1135
+ const t = await x.api().patch(`/tasks/${seg(k)}/subtasks/${seg(id)}`, body);
1136
+ const s = t.subtasks.find(s => s.id === id);
1137
+ x.emit(t, () => x.print(`${verb} “${s?.title ?? id}” on ${x.bold(t.key)}`));
1138
+ }
1139
+ function teamBody(x) {
1140
+ const b = {};
1141
+ if (x.o.desc != null) b.description = x.o.desc;
1142
+ if (x.o.icon != null) b.icon = x.o.icon;
1143
+ if (x.o.color != null) b.color = x.o.color;
1144
+ if (x.o.members != null) b.members = /^none$/i.test(x.o.members) ? [] : list(x.o.members);
1145
+ return b;
1146
+ }
1147
+ function botBody(x) {
1148
+ const b = {};
1149
+ if (x.o.role != null) b.role = choice(x, x.o.role, ['Member', 'Guest'], 'A bot’s role');
1150
+ if (x.o.projects != null) b.projects = /^none$/i.test(x.o.projects) ? [] : list(x.o.projects);
1151
+ return b;
1152
+ }
1153
+
1154
+ /* `react` / `unreact`: emoji or its name. */
1155
+ export function reaction(x, v) {
1156
+ const e = REACTIONS[String(v).toLowerCase()] || v;
1157
+ if (!Object.values(REACTIONS).includes(e)) x.usage('Reactions are 👍 🎉 ❤️ 👀 🚀 ✅ (or thumbsup, tada, heart, eyes, rocket, check).');
1158
+ return e;
1159
+ }
1160
+
1161
+ /* `sudo-cli api <METHOD> <path>`: any endpoint, JSON in and out. -f key=value sends a string,
1162
+ -F key=value a typed value (true, 3, null, [..], {..}); on GET they go in the query. */
1163
+ export async function rawApi(x) {
1164
+ const [m, p] = x.args;
1165
+ const method = String(m || '').toUpperCase();
1166
+ if (!['GET', 'POST', 'PATCH', 'PUT', 'DELETE'].includes(method) || !p)
1167
+ x.usage('sudo-cli api <GET|POST|PATCH|PUT|DELETE> </path> [--data <json>] [-f key=value]... [-F key=value]...');
1168
+ const u = new URL(p.replace(/^\/?(api\/v1)?\/?/, '/'), 'http://x');
1169
+ const fields = {};
1170
+ for (const [list_, typed] of [
1171
+ [x.o['raw-field'], false],
1172
+ [x.o.field, true],
1173
+ ])
1174
+ for (const f of list_ || []) {
1175
+ const i = f.indexOf('=');
1176
+ if (i < 1) x.usage(`A field is key=value, not “${f}”.`);
1177
+ const v = f.slice(i + 1);
1178
+ let val = v;
1179
+ if (typed)
1180
+ try {
1181
+ val = JSON.parse(v);
1182
+ } catch {
1183
+ /* not JSON: the string */
1184
+ }
1185
+ fields[f.slice(0, i)] = val;
1186
+ }
1187
+ let body;
1188
+ if (x.o.data != null) {
1189
+ try {
1190
+ body = JSON.parse(x.o.data);
1191
+ } catch {
1192
+ x.usage('--data must be JSON.');
1193
+ }
1194
+ }
1195
+ const query = Object.fromEntries(u.searchParams);
1196
+ if (method === 'GET' || method === 'DELETE') Object.assign(query, fields);
1197
+ else if (Object.keys(fields).length) body = { ...(body || {}), ...fields };
1198
+ const r = await x
1199
+ .api()
1200
+ .request(method, u.pathname, { query, ...(body !== undefined ? { body } : method === 'GET' || method === 'DELETE' ? {} : { body: {} }) });
1201
+ x.json(r);
1202
+ }