@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.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @sudosapient/cli (`sudo-cli`)
2
2
 
3
- Sudo Sapient from the terminal: capture, list, assign, comment on and complete tasks, with `--json`
4
- for scripts. It talks only to the public API (`/api/v1`) with a personal API key, so it can do exactly
3
+ Sudo Sapient from the terminal: everything you can do in the app, from capturing a task to sharing a
4
+ project, uploading a file or managing people, with `--json` for scripts. It talks only to the public API (`/api/v1`) with a personal API key, so it can do exactly
5
5
  what you can do in the app, and its changes show in Activity as "you via CLI".
6
6
 
7
7
  ## Install
@@ -60,10 +60,51 @@ sudo-cli open WEB-212 # in the browser (--print just prints the URL)
60
60
  sudo-cli tasks --json | jq '.[].key'
61
61
  ```
62
62
 
63
+ ### Everything else
64
+
65
+ `sudo-cli help <command>` lists a command's subcommands and options (or `sudo-cli <command> --help`).
66
+
67
+ ```bash
68
+ sudo-cli tasks edit WEB-212 --status review --label bug,frontend --repeat weekly --depends WEB-200
69
+ sudo-cli tasks archive WEB-212 · tasks restore · tasks dup · tasks fav · tasks rm · tasks undelete <id>
70
+ sudo-cli subtask add WEB-212 "Write tests" -a grace # ids come from sudo-cli show WEB-212
71
+ sudo-cli subtask done WEB-212 <id> · subtask edit · subtask rm · subtask promote
72
+ sudo-cli comment edit <id> "…" · comment rm <id> · comment undelete <id> · react <id> 👍 · unreact <id> 👍
73
+ sudo-cli projects new "Mobile App" --prefix MOB --private --members grace --perm edit
74
+ sudo-cli projects edit MOB --status risk --due 2026-12-01 --milestone "Beta=2026-11-20"
75
+ sudo-cli projects share MOB grace@example.com --perm comment · projects unshare MOB grace
76
+ sudo-cli projects show · archive · restore · dup · rm · request-access · fav · order WEB MOB
77
+ sudo-cli teams new Platform --members grace,ada · teams edit · teams rm
78
+ sudo-cli people invite cara@example.com --role Guest · people resend · people edit · people role grace Admin · people rm
79
+ sudo-cli files upload ./spec.pdf --task WEB-212 · files download <id> [-o path|-] · files attach <id> WEB-212
80
+ sudo-cli files rename · detach · dup · rm
81
+ sudo-cli events new "Launch" --date 2026-12-01 --time 09:30 -p WEB · events --from today --to +30d
82
+ sudo-cli views new "Urgent" -p WEB --type board --filter priority=urgent,high --filter assignee!=me
83
+ sudo-cli inbox --unread · inbox read <id> · inbox read --all · activity -p WEB · search "login"
84
+ sudo-cli me edit --title Engineer · prefs set theme=dark tz=Europe/Berlin · notify-prefs set email_comment=on
85
+ sudo-cli workspace · favorites
86
+ sudo-cli api GET /tasks -f project=WEB # any endpoint; -f sends strings, -F typed values, --data JSON
87
+ ```
88
+
89
+ - **Admin commands** (`people role`, `people rm`, others' names and titles, `workspace edit`,
90
+ `workspace invite-link`, `bots …`) work when the key's person is an Owner or Admin, as in the app.
91
+ For anyone else they fail with "Only owners and admins can do this".
92
+ - **Deleting** a project, person, bot, team or file asks first; `--yes` (`-y`) skips the question,
93
+ and without a terminal to ask at (a script) `--yes` is required. Tasks and comments don't ask:
94
+ `tasks rm` and `comment rm` print how to undo them, which works for 10 minutes.
95
+ - **Downloads** go to the file's own name in the current directory (reduced to a plain file name), or
96
+ `-o <path>`, or `-o -` for stdout. An existing file is never overwritten without `--force`.
97
+ - Bot keys are made in Settings → API → Bots only; `bots` can list, change, remove and revoke them.
98
+ - `none` clears a field: `--due none`, `--team none`, `--repeat none`, `--label none`.
99
+
63
100
  Due dates: `today`, `tomorrow`, a weekday (`friday`, `fri`; today counts), `next week`, `+3d`, `+2w`, or
64
101
  `2026-11-20`. Set `SUDO_PROJECT=WEB` to skip `-p`. Exit codes: `0` ok, `1` the API refused (the
65
102
  message says why; with `--json` it's `{ error, code }` on stderr), `2` a usage error.
66
103
 
104
+ Your keys share one rate limit (120 requests a minute by default). When a bulk run reaches it,
105
+ `sudo-cli` waits until the server says it may go on (`rate limited; retrying in 53 s…` on stderr) and
106
+ then carries on, up to 3 times per request.
107
+
67
108
  ## License
68
109
 
69
110
  UNLICENSED, all rights reserved. The package is public on npm so anyone can install and run it, but
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sudosapient/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Sudo Sapient from the terminal: capture, list, assign and complete tasks.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,7 @@
19
19
  "README.md"
20
20
  ],
21
21
  "dependencies": {
22
- "@sudosapient/client": "0.1.0"
22
+ "@sudosapient/client": "0.2.0"
23
23
  },
24
24
  "engines": {
25
25
  "node": ">=22"
package/src/cli.js CHANGED
@@ -7,11 +7,10 @@ import readline from 'node:readline';
7
7
  import { parseArgs } from 'node:util';
8
8
  import { ApiError, createClient, plainHttpRemote, seg } from '@sudosapient/client';
9
9
  import { clearConfig, configPath, loadConfig, saveConfig } from '@sudosapient/client/config';
10
+ import { ALIASES, NOUNS, PRIORITIES, STATUSES, nounHelp, rawApi, reaction } from './commands.js';
10
11
 
11
12
  // Kept equal to package.json by release-please (extra-files in release-please-config.json).
12
- export const VERSION = '0.1.0'; // x-release-please-version
13
- const STATUSES = ['backlog', 'todo', 'progress', 'review', 'done'];
14
- const PRIORITIES = ['urgent', 'high', 'medium', 'low', 'none'];
13
+ export const VERSION = '0.2.0'; // x-release-please-version
15
14
 
16
15
  export const HELP = `sudo-cli: Sudo Sapient from the terminal
17
16
 
@@ -27,8 +26,19 @@ Usage
27
26
  sudo-cli comment <KEY> "<text>"
28
27
  sudo-cli projects [list]
29
28
  sudo-cli open <KEY> opens a task or project in the browser (--print: just the URL)
29
+ sudo-cli search "<text>"
30
+
31
+ More (sudo-cli help <command> lists each one's subcommands)
32
+ tasks edit, rm, undelete, archive, restore, dup, fav, unfav
33
+ subtask add, done, undo, edit, rm, promote
34
+ comment edit, rm, undelete; react / unreact <comment id> <emoji>
35
+ projects show, new, edit, archive, restore, dup, rm, share, unshare, request-access, fav, unfav, order
36
+ teams, people, bots, files, events, views, inbox, activity, workspace, me, prefs, notify-prefs, favorites
37
+ api <METHOD> <path> [--data <json>] [-f key=value]... [-F key=value]... any endpoint, JSON out
30
38
 
31
39
  Shortcuts: new, done, assign, show (= tasks …). Dates: today, tomorrow, friday, +3d, +2w, 2026-11-20.
40
+ Deleting a project, person, bot, team or file asks first (--yes to skip; required without a
41
+ terminal). Deleted tasks and comments can be undeleted for 10 minutes, so they don't ask.
32
42
  Options: --json (machine-readable output), --url, --insecure (allow a plain http:// URL to another
33
43
  machine; login remembers it). The key comes from login, else SUDO_API_KEY; --key works too, but it
34
44
  lands in your shell history and the process list, so prefer the prompt or the variable.
@@ -50,6 +60,42 @@ const OPTIONS = {
50
60
  mine: { type: 'boolean' },
51
61
  all: { type: 'boolean' },
52
62
  print: { type: 'boolean' },
63
+ archived: { type: 'boolean' },
64
+ title: { type: 'string' },
65
+ start: { type: 'string' },
66
+ label: { type: 'string', multiple: true },
67
+ repeat: { type: 'string' },
68
+ estimate: { type: 'string' },
69
+ depends: { type: 'string' },
70
+ order: { type: 'string' },
71
+ yes: { type: 'boolean', short: 'y' },
72
+ force: { type: 'boolean' },
73
+ output: { type: 'string', short: 'o' },
74
+ task: { type: 'string' },
75
+ perm: { type: 'string' },
76
+ name: { type: 'string' },
77
+ lead: { type: 'string' },
78
+ team: { type: 'string' },
79
+ private: { type: 'boolean' },
80
+ public: { type: 'boolean' },
81
+ milestone: { type: 'string', multiple: true },
82
+ members: { type: 'string' },
83
+ projects: { type: 'string' },
84
+ role: { type: 'string' },
85
+ color: { type: 'string' },
86
+ icon: { type: 'string' },
87
+ prefix: { type: 'string' },
88
+ unread: { type: 'boolean' },
89
+ from: { type: 'string' },
90
+ to: { type: 'string' },
91
+ date: { type: 'string' },
92
+ time: { type: 'string' },
93
+ type: { type: 'string' },
94
+ filter: { type: 'string', multiple: true },
95
+ brand: { type: 'string' },
96
+ data: { type: 'string' },
97
+ field: { type: 'string', short: 'F', multiple: true },
98
+ 'raw-field': { type: 'string', short: 'f', multiple: true },
53
99
  help: { type: 'boolean', short: 'h' },
54
100
  version: { type: 'boolean', short: 'v' },
55
101
  };
@@ -114,18 +160,43 @@ export async function run(argv, io = {}) {
114
160
  err.write(`sudo-cli: ${e.message}\nRun sudo-cli --help for usage.\n`);
115
161
  return 2;
116
162
  }
163
+ const noun = n => (NOUNS[n] ? n : ALIASES[n]);
164
+ // `sudo-cli help tasks` and `sudo-cli tasks --help` list a command's subcommands.
165
+ if (pos[0] === 'help' || (o.help && noun(pos[0]))) {
166
+ const n = noun(pos[0] === 'help' ? pos[1] : pos[0]);
167
+ print(n ? nounHelp(n) : HELP);
168
+ return 0;
169
+ }
117
170
  if (o.version || o.help || !pos.length) {
118
171
  print(o.version ? VERSION : HELP);
119
172
  return o.version || o.help ? 0 : 2;
120
173
  }
121
174
 
122
- // `tasks new …` and `new …` are the same command.
175
+ // `tasks new …` and `new …` are the same command. Subcommands with a handler in commands.js
176
+ // (`tasks edit`, `projects share`, `teams`, …) run from its table.
123
177
  let [cmd, ...args] = pos;
124
- if (cmd === 'tasks' || cmd === 'task') {
178
+ let table = null;
179
+ const verbOf = (n, v) => (NOUNS[n].verbs[v]?.run ? NOUNS[n].verbs[v] : null);
180
+ const n0 = noun(cmd);
181
+ if (n0 === 'tasks') {
125
182
  const sub = args[0];
126
- [cmd, ...args] = ['new', 'done', 'assign', 'show', 'list'].includes(sub) ? [sub === 'list' ? 'ls' : sub, ...args.slice(1)] : ['ls', ...args];
183
+ if (verbOf('tasks', sub)) table = { noun: 'tasks', verb: sub, args: args.slice(1) };
184
+ else [cmd, ...args] = ['new', 'done', 'assign', 'show', 'list'].includes(sub) ? [sub === 'list' ? 'ls' : sub, ...args.slice(1)] : ['ls', ...args];
185
+ } else if (n0 === 'projects') {
186
+ if (verbOf('projects', args[0])) table = { noun: 'projects', verb: args[0], args: args.slice(1) };
187
+ else [cmd, args] = ['projects', args[0] === 'list' ? args.slice(1) : args];
188
+ } else if (n0 === 'comment') {
189
+ if (verbOf('comment', args[0])) table = { noun: 'comment', verb: args[0], args: args.slice(1) };
190
+ else cmd = 'comment';
191
+ } else if (n0 === 'me' && !args.length) cmd = 'whoami';
192
+ else if (n0) {
193
+ const v = verbOf(n0, args[0]) ? args[0] : !args.length || args[0].startsWith('-') ? NOUNS[n0].default : null;
194
+ if (!v || !verbOf(n0, v)) {
195
+ err.write(`sudo-cli: Unknown command “${[cmd, args[0]].filter(Boolean).join(' ')}”. Run sudo-cli help ${n0}.\n`);
196
+ return 2;
197
+ }
198
+ table = { noun: n0, verb: v, args: v === args[0] ? args.slice(1) : args };
127
199
  }
128
- if (cmd === 'projects' || cmd === 'project') [cmd, args] = ['projects', args[0] === 'list' ? args.slice(1) : args];
129
200
 
130
201
  const cfg = loadConfig(env);
131
202
  const url = (o.url || cfg.url || '').replace(/\/+$/, '');
@@ -138,17 +209,19 @@ export async function run(argv, io = {}) {
138
209
  if (plainHttpRemote(u) && !ok)
139
210
  throw new UsageError(`${u} is plain http, so your API key would cross the network unencrypted. Use https, or add --insecure if you mean it.`);
140
211
  };
212
+ // The client waits out rate limits (Retry-After) by itself; say so, or a bulk run looks stuck.
213
+ const rateLimited = ({ delay }) => err.write(`sudo-cli: rate limited; retrying in ${Math.ceil(delay / 1000)} s…\n`);
141
214
  const api = () => {
142
215
  if (!url || !key) throw new UsageError('Not logged in. Run: sudo-cli login --url https://your-workspace');
143
216
  needSecure(url);
144
- const a = createClient({ url, key, client: 'CLI', fetch: io.fetch });
217
+ const a = createClient({ url, key, client: 'CLI', fetch: io.fetch, sleep: io.sleep, onRetry: rateLimited });
145
218
  // JSON output escapes control characters itself; human output gets them stripped.
146
219
  if (o.json) return a;
147
220
  const wrap =
148
221
  f =>
149
222
  async (...args) =>
150
223
  clean(await f(...args));
151
- return { ...a, get: wrap(a.get), post: wrap(a.post), patch: wrap(a.patch), all: wrap(a.all) };
224
+ return { ...a, get: wrap(a.get), post: wrap(a.post), patch: wrap(a.patch), put: wrap(a.put), del: wrap(a.del), upload: wrap(a.upload), all: wrap(a.all) };
152
225
  };
153
226
  const json = v => print(JSON.stringify(v, null, 2));
154
227
  const who = async a => new Map((await a.all('/members')).map(m => [m.id, m.name]));
@@ -160,7 +233,38 @@ export async function run(argv, io = {}) {
160
233
  return v;
161
234
  };
162
235
 
236
+ /* What commands.js handlers get. */
237
+ const x = {
238
+ o,
239
+ io: { ...io, stdout: out },
240
+ url,
241
+ api,
242
+ print,
243
+ json,
244
+ bold,
245
+ dim,
246
+ pad,
247
+ need,
248
+ names: who,
249
+ usage: msg => {
250
+ throw new UsageError(msg);
251
+ },
252
+ date: v => (/^none$/i.test(String(v)) ? null : parseDue(v)),
253
+ emit: (data, human) => (o.json ? json(data) : human(data)),
254
+ cancelled: () => (err.write('sudo-cli: cancelled.\n'), 1),
255
+ /* A yes at the terminal, or --yes. Without a terminal to ask at, --yes is required. */
256
+ async confirm(question) {
257
+ if (o.yes) return true;
258
+ if (!(io.stdin || process.stdin).isTTY) throw new UsageError(`${question} Pass --yes to go ahead (there's no terminal to ask at).`);
259
+ return /^y(es)?$/i.test((await ask(io, `${question} [y/N] `)).trim());
260
+ },
261
+ };
262
+
163
263
  try {
264
+ if (table) {
265
+ x.args = table.args;
266
+ return (await NOUNS[table.noun].verbs[table.verb].run(x)) || 0;
267
+ }
164
268
  switch (cmd) {
165
269
  case 'login': {
166
270
  const u = (o.url || (await ask(io, `Workspace URL${cfg.url ? ` (${cfg.url})` : ''}: `)) || cfg.url || '').trim().replace(/\/+$/, '');
@@ -169,7 +273,7 @@ export async function run(argv, io = {}) {
169
273
  needSecure(u, o.insecure);
170
274
  const k = (o.key || (await ask(io, 'API key (Settings → API): ', { hidden: true }))).trim();
171
275
  need(k, 'Paste an API key from Settings → API.');
172
- const me = clean(await createClient({ url: u, key: k, client: 'CLI', fetch: io.fetch }).get('/me'));
276
+ const me = clean(await createClient({ url: u, key: k, client: 'CLI', fetch: io.fetch, sleep: io.sleep, onRetry: rateLimited }).get('/me'));
173
277
  const file = saveConfig({ url: u, key: k, ...(o.insecure && plainHttpRemote(u) ? { insecure: true } : {}) }, env);
174
278
  if (o.json) json({ ok: true, user: me, config: file });
175
279
  else print(`Logged in to ${me.workspace || u} as ${me.name} (${me.key.scope} key “${me.key.name}”). Saved to ${file}`);
@@ -213,7 +317,8 @@ export async function run(argv, io = {}) {
213
317
  const mine = o.mine || !project;
214
318
  const status = o.status || (o.all ? undefined : 'open');
215
319
  const n = Math.min(Math.max(Number(o.limit) || 50, 1), 500);
216
- const [tasks, names] = await Promise.all([a.all('/tasks', { project, status, assignee: mine ? 'me' : undefined }, n), who(a)]);
320
+ const query = { project, status, assignee: mine ? 'me' : undefined, ...(o.archived ? { includeArchived: 'true' } : {}) };
321
+ const [tasks, names] = await Promise.all([a.all('/tasks', query, n), who(a)]);
217
322
  if (o.json) {
218
323
  json(tasks);
219
324
  return 0;
@@ -276,8 +381,17 @@ export async function run(argv, io = {}) {
276
381
  print(`${bold(t.key)} ${t.title}`);
277
382
  print(dim(`${t.status} · ${t.priority} · ${names.get(t.assignee) || 'unassigned'}${t.due ? ` · due ${t.due.slice(0, 10)}` : ''}`));
278
383
  if (t.description) print('\n' + t.description);
279
- for (const s of t.subtasks) print(` [${s.done ? 'x' : ' '}] ${s.title}`);
280
- for (const c of comments.data) print(`\n${bold(names.get(c.author) || '?')} ${dim(new Date(c.createdAt).toLocaleString())}\n${c.text}`);
384
+ for (const s of t.subtasks)
385
+ print(
386
+ ` [${s.done ? 'x' : ' '}] ${s.title}${s.assignee ? ` · ${names.get(s.assignee) || '?'}` : ''}${s.due ? ` · ${s.due.slice(0, 10)}` : ''} ${dim(s.id)}`,
387
+ );
388
+ for (const c of comments.data)
389
+ print(
390
+ `\n${bold(names.get(c.author) || '?')} ${dim(new Date(c.createdAt).toLocaleString())} ${dim(c.id)}\n${c.text}${Object.entries(c.reactions || {})
391
+ .map(([e, w]) => ` ${e}${w.length}`)
392
+ .join('')
393
+ .replace(/^ /, '\n')}`,
394
+ );
281
395
  print('\n' + dim(t.url));
282
396
  return 0;
283
397
  }
@@ -291,13 +405,15 @@ export async function run(argv, io = {}) {
291
405
  return 0;
292
406
  }
293
407
  case 'projects': {
294
- const ps = await api().all('/projects');
408
+ const ps = await api().all('/projects', o.archived ? { includeArchived: 'true' } : {});
295
409
  if (o.json) {
296
410
  json(ps);
297
411
  return 0;
298
412
  }
299
413
  for (const p of ps)
300
- print(`${bold(pad(p.key, 8))} ${pad(p.name, 28)} ${dim(`${p.openTasks} open · ${p.doneTasks} done${p.private ? ' · private' : ''}`)}`);
414
+ print(
415
+ `${bold(pad(p.key, 8))} ${pad(p.name, 28)} ${dim(`${p.openTasks} open · ${p.doneTasks} done${p.private ? ' · private' : ''}${p.archived ? ' · archived' : ''}`)}`,
416
+ );
301
417
  return 0;
302
418
  }
303
419
  case 'open': {
@@ -325,6 +441,33 @@ export async function run(argv, io = {}) {
325
441
  .unref();
326
442
  return 0;
327
443
  }
444
+ case 'react':
445
+ case 'unreact': {
446
+ const [id, e] = args;
447
+ need(id && e, `sudo-cli ${cmd} <comment id> 👍 (comment ids are in sudo-cli show <KEY>)`);
448
+ const path = `/comments/${seg(id)}/reactions/${seg(reaction(x, e))}`;
449
+ const c = cmd === 'react' ? await api().put(path) : await api().del(path);
450
+ x.emit(c, () => print(`${cmd === 'react' ? 'Reacted' : 'Took back'} ${reaction(x, e)}`));
451
+ return 0;
452
+ }
453
+ case 'search': {
454
+ const q = need(args.join(' ').trim(), 'What to search for? sudo-cli search "login bug"');
455
+ const a = api();
456
+ const [r, names] = await Promise.all([a.get('/search', { q }), who(a)]);
457
+ if (o.json) {
458
+ json(r);
459
+ return 0;
460
+ }
461
+ for (const t of r.tasks) print(row(t, names));
462
+ for (const p of r.projects) print(`${bold(pad(p.key, 9))} ${dim('project')} ${p.name}`);
463
+ for (const m of r.members) print(`${bold(pad(m.name, 22))} ${dim(m.email || m.kind)}`);
464
+ if (!r.tasks.length && !r.projects.length && !r.members.length) print(dim('No matches.'));
465
+ return 0;
466
+ }
467
+ case 'api':
468
+ x.args = args;
469
+ await rawApi(x);
470
+ return 0;
328
471
  default:
329
472
  throw new UsageError(`Unknown command “${cmd}”. Run sudo-cli --help.`);
330
473
  }