@sudosapient/cli 0.0.0-stage → 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,3 +1,111 @@
1
- # Temporary Holding Version
1
+ # @sudosapient/cli (`sudo-cli`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ what you can do in the app, and its changes show in Activity as "you via CLI".
6
+
7
+ ## Install
8
+
9
+ With Node 22 or later, run it straight from npm, or install it to get the `sudo-cli` command:
10
+
11
+ ```bash
12
+ npx @sudosapient/cli login --url https://sudo.example.com
13
+ npm install -g @sudosapient/cli # then: sudo-cli …
14
+ ```
15
+
16
+ Mind the scope: the unscoped `sudo-cli` package on npm is **someone else's**; don't `npx sudo-cli`.
17
+
18
+ From a checkout of this repo instead:
19
+
20
+ ```bash
21
+ pnpm install
22
+ ln -s "$PWD/packages/cli/bin/sudo-cli.js" ~/.local/bin/sudo-cli # any directory on your PATH
23
+ # or run it in place: node packages/cli/bin/sudo-cli.js …
24
+ ```
25
+
26
+ ## Log in
27
+
28
+ Create a key in **Settings → API** (Read and write, unless you only want to look), then:
29
+
30
+ ```bash
31
+ sudo-cli login --url https://sudo.example.com # paste the key when asked
32
+ sudo-cli whoami
33
+ ```
34
+
35
+ Give the key at the prompt (it isn't echoed) or pipe it in (`printf '%s\n%s\n' "$URL" "$KEY" | sudo-cli
36
+ login`), or set `SUDO_API_KEY`. `--key` works too, but the key then lands in your shell history and
37
+ in the process list where other users of the machine can see it, so `sudo-cli` warns when it's used.
38
+
39
+ The URL and key are saved to `$XDG_CONFIG_HOME/sudo-sapient/config.json` (default `~/.config`), readable
40
+ only by you (the directory is kept owner-only too). `SUDO_URL` and `SUDO_API_KEY` override it (handy
41
+ in CI). `sudo-cli logout` deletes it and, when you answer yes (or pass `--revoke`), also revokes the
42
+ key on the server so it stops working everywhere; otherwise revoke it in Settings → API.
43
+
44
+ The key travels with every request, so `sudo-cli` refuses a plain `http://` URL unless it's this
45
+ machine (`localhost`, `127.0.0.1`). For a workspace you really reach over plain http, add
46
+ `--insecure`; `login --insecure` remembers that for the saved URL.
47
+
48
+ ## Use
49
+
50
+ ```bash
51
+ sudo-cli new "Fix login bug" -p WEB --due friday -a me --priority high # quick capture
52
+ sudo-cli tasks # my open tasks, newest first
53
+ sudo-cli tasks -p WEB --all # every task in WEB, done ones too
54
+ sudo-cli show WEB-212
55
+ sudo-cli comment WEB-212 "Repro'd on Safari"
56
+ sudo-cli assign WEB-212 grace@example.com # me, a name, an email, or none
57
+ sudo-cli done WEB-212 WEB-213
58
+ sudo-cli projects
59
+ sudo-cli open WEB-212 # in the browser (--print just prints the URL)
60
+ sudo-cli tasks --json | jq '.[].key'
61
+ ```
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
+
100
+ Due dates: `today`, `tomorrow`, a weekday (`friday`, `fri`; today counts), `next week`, `+3d`, `+2w`, or
101
+ `2026-11-20`. Set `SUDO_PROJECT=WEB` to skip `-p`. Exit codes: `0` ok, `1` the API refused (the
102
+ message says why; with `--json` it's `{ error, code }` on stderr), `2` a usage error.
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
+
108
+ ## License
109
+
110
+ UNLICENSED, all rights reserved. The package is public on npm so anyone can install and run it, but
111
+ no license to copy, modify or redistribute the code is granted.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ import { run } from '../src/cli.js';
3
+
4
+ process.exitCode = await run(process.argv.slice(2));
package/package.json CHANGED
@@ -1,6 +1,31 @@
1
1
  {
2
2
  "name": "@sudosapient/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Sudo Sapient from the terminal: capture, list, assign and complete tasks.",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/sudosapient-labs/project-tracker.git",
8
+ "directory": "packages/cli"
9
+ },
10
+ "homepage": "https://github.com/sudosapient-labs/project-tracker/tree/prod/packages/cli#readme",
11
+ "bugs": "https://github.com/sudosapient-labs/project-tracker/issues",
12
+ "type": "module",
13
+ "bin": {
14
+ "sudo-cli": "./bin/sudo-cli.js"
15
+ },
16
+ "files": [
17
+ "bin",
18
+ "src",
19
+ "README.md"
20
+ ],
21
+ "dependencies": {
22
+ "@sudosapient/client": "0.2.0"
23
+ },
24
+ "engines": {
25
+ "node": ">=22"
26
+ },
27
+ "license": "UNLICENSED",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ }
6
31
  }
package/src/cli.js ADDED
@@ -0,0 +1,511 @@
1
+ /* sudo-cli: Sudo Sapient from the terminal, over the public API (/api/v1) with a personal key.
2
+ `run(argv, io)` returns the exit code; io carries env, output streams and fetch, so tests can run
3
+ it without a terminal or a network. */
4
+ import { randomUUID } from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+ import readline from 'node:readline';
7
+ import { parseArgs } from 'node:util';
8
+ import { ApiError, createClient, plainHttpRemote, seg } from '@sudosapient/client';
9
+ import { clearConfig, configPath, loadConfig, saveConfig } from '@sudosapient/client/config';
10
+ import { ALIASES, NOUNS, PRIORITIES, STATUSES, nounHelp, rawApi, reaction } from './commands.js';
11
+
12
+ // Kept equal to package.json by release-please (extra-files in release-please-config.json).
13
+ export const VERSION = '0.2.0'; // x-release-please-version
14
+
15
+ export const HELP = `sudo-cli: Sudo Sapient from the terminal
16
+
17
+ Usage
18
+ sudo-cli login [--url <workspace url>] asks for the key (or pipe it in)
19
+ sudo-cli logout [--revoke] --revoke: also revoke the key on the server
20
+ sudo-cli whoami
21
+ sudo-cli tasks [list] [--mine] [-p <project>] [-s <status>] [--all] [-n <limit>]
22
+ sudo-cli tasks new "<title>" -p <project> [--due <date>] [-a <who>] [--priority <p>] [-d <text>]
23
+ sudo-cli tasks done <KEY>...
24
+ sudo-cli tasks assign <KEY> <who> who: me, a name, an email, or "none"
25
+ sudo-cli tasks show <KEY>
26
+ sudo-cli comment <KEY> "<text>"
27
+ sudo-cli projects [list]
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
38
+
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.
42
+ Options: --json (machine-readable output), --url, --insecure (allow a plain http:// URL to another
43
+ machine; login remembers it). The key comes from login, else SUDO_API_KEY; --key works too, but it
44
+ lands in your shell history and the process list, so prefer the prompt or the variable.
45
+ Create a key in Settings → API. Config: ${'$'}XDG_CONFIG_HOME/sudo-sapient/config.json.`;
46
+
47
+ const OPTIONS = {
48
+ json: { type: 'boolean' },
49
+ url: { type: 'string' },
50
+ key: { type: 'string' },
51
+ insecure: { type: 'boolean' },
52
+ revoke: { type: 'boolean' },
53
+ project: { type: 'string', short: 'p' },
54
+ status: { type: 'string', short: 's' },
55
+ assignee: { type: 'string', short: 'a' },
56
+ due: { type: 'string' },
57
+ priority: { type: 'string' },
58
+ desc: { type: 'string', short: 'd' },
59
+ limit: { type: 'string', short: 'n' },
60
+ mine: { type: 'boolean' },
61
+ all: { type: 'boolean' },
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 },
99
+ help: { type: 'boolean', short: 'h' },
100
+ version: { type: 'boolean', short: 'v' },
101
+ };
102
+
103
+ /* Everyday dates to YYYY-MM-DD, in local time. A weekday is its next occurrence (today counts). */
104
+ const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
105
+ export function parseDue(s, now = new Date()) {
106
+ const v = String(s).trim().toLowerCase();
107
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
108
+ const inDays = n => {
109
+ const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + n);
110
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
111
+ };
112
+ if (v === 'today') return inDays(0);
113
+ if (v === 'tomorrow') return inDays(1);
114
+ const rel = /^\+?(\d{1,3})\s*(d|w)$/.exec(v);
115
+ if (rel) return inDays(Number(rel[1]) * (rel[2] === 'w' ? 7 : 1));
116
+ if (v === 'next week') return inDays((8 - now.getDay()) % 7 || 7);
117
+ const day = DAYS.findIndex(n => n === v || n.slice(0, 3) === v);
118
+ if (day >= 0) return inDays((day - now.getDay() + 7) % 7);
119
+ throw new UsageError(`Can't read the date “${s}”. Try today, tomorrow, friday, +3d or 2026-11-20.`);
120
+ }
121
+
122
+ class UsageError extends Error {}
123
+
124
+ /* `link` is on the same site as the workspace (scheme, host and port), so nothing the server sends
125
+ can make the CLI open some other page or program. */
126
+ export function sameOrigin(link, workspace) {
127
+ try {
128
+ return new URL(link).origin === new URL(workspace).origin;
129
+ } catch {
130
+ return false;
131
+ }
132
+ }
133
+
134
+ /* Text from the workspace is printed to a terminal: drop control characters (escape sequences could
135
+ rewrite the screen or the clipboard), keeping newlines and tabs. */
136
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
137
+ const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
138
+ export const clean = v =>
139
+ typeof v === 'string'
140
+ ? v.replace(CONTROL, '')
141
+ : Array.isArray(v)
142
+ ? v.map(clean)
143
+ : v && typeof v === 'object'
144
+ ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clean(x)]))
145
+ : v;
146
+
147
+ export async function run(argv, io = {}) {
148
+ const env = io.env || process.env;
149
+ const out = io.stdout || process.stdout;
150
+ const err = io.stderr || process.stderr;
151
+ const print = s => out.write(s + '\n');
152
+ const color = !env.NO_COLOR && out.isTTY;
153
+ const dim = s => (color ? `\x1b[2m${s}\x1b[0m` : s);
154
+ const bold = s => (color ? `\x1b[1m${s}\x1b[0m` : s);
155
+
156
+ let o, pos;
157
+ try {
158
+ ({ values: o, positionals: pos } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true }));
159
+ } catch (e) {
160
+ err.write(`sudo-cli: ${e.message}\nRun sudo-cli --help for usage.\n`);
161
+ return 2;
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
+ }
170
+ if (o.version || o.help || !pos.length) {
171
+ print(o.version ? VERSION : HELP);
172
+ return o.version || o.help ? 0 : 2;
173
+ }
174
+
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.
177
+ let [cmd, ...args] = pos;
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') {
182
+ const sub = args[0];
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 };
199
+ }
200
+
201
+ const cfg = loadConfig(env);
202
+ const url = (o.url || cfg.url || '').replace(/\/+$/, '');
203
+ const key = o.key || cfg.key;
204
+ if (o.key)
205
+ err.write('sudo-cli: warning: --key leaves the key in your shell history and the process list. Prefer the prompt (sudo-cli login) or SUDO_API_KEY.\n');
206
+ // The key goes in a header on every request: never in the clear to another machine unless asked.
207
+ const insecureOk = o.insecure || (!o.url && cfg.insecure);
208
+ const needSecure = (u, ok = insecureOk) => {
209
+ if (plainHttpRemote(u) && !ok)
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.`);
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`);
214
+ const api = () => {
215
+ if (!url || !key) throw new UsageError('Not logged in. Run: sudo-cli login --url https://your-workspace');
216
+ needSecure(url);
217
+ const a = createClient({ url, key, client: 'CLI', fetch: io.fetch, sleep: io.sleep, onRetry: rateLimited });
218
+ // JSON output escapes control characters itself; human output gets them stripped.
219
+ if (o.json) return a;
220
+ const wrap =
221
+ f =>
222
+ async (...args) =>
223
+ clean(await f(...args));
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) };
225
+ };
226
+ const json = v => print(JSON.stringify(v, null, 2));
227
+ const who = async a => new Map((await a.all('/members')).map(m => [m.id, m.name]));
228
+ const pad = (s, n) => String(s ?? '').padEnd(n);
229
+ const row = (t, names) =>
230
+ `${bold(pad(t.key, 9))} ${pad(t.status, 8)} ${pad(t.priority === 'none' ? '' : t.priority, 6)} ${pad(names.get(t.assignee) || dim('—'), 14)} ${pad(t.due ? t.due.slice(0, 10) : '', 10)} ${t.title}`;
231
+ const need = (v, msg) => {
232
+ if (!v) throw new UsageError(msg);
233
+ return v;
234
+ };
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
+
263
+ try {
264
+ if (table) {
265
+ x.args = table.args;
266
+ return (await NOUNS[table.noun].verbs[table.verb].run(x)) || 0;
267
+ }
268
+ switch (cmd) {
269
+ case 'login': {
270
+ const u = (o.url || (await ask(io, `Workspace URL${cfg.url ? ` (${cfg.url})` : ''}: `)) || cfg.url || '').trim().replace(/\/+$/, '');
271
+ need(/^https?:\/\//.test(u), 'Give the workspace URL, e.g. https://sudo.example.com');
272
+ // Logging in again asks again: the saved consent was for the saved URL.
273
+ needSecure(u, o.insecure);
274
+ const k = (o.key || (await ask(io, 'API key (Settings → API): ', { hidden: true }))).trim();
275
+ need(k, 'Paste an API key from Settings → API.');
276
+ const me = clean(await createClient({ url: u, key: k, client: 'CLI', fetch: io.fetch, sleep: io.sleep, onRetry: rateLimited }).get('/me'));
277
+ const file = saveConfig({ url: u, key: k, ...(o.insecure && plainHttpRemote(u) ? { insecure: true } : {}) }, env);
278
+ if (o.json) json({ ok: true, user: me, config: file });
279
+ else print(`Logged in to ${me.workspace || u} as ${me.name} (${me.key.scope} key “${me.key.name}”). Saved to ${file}`);
280
+ return 0;
281
+ }
282
+ case 'logout': {
283
+ // Revoke the key on the server too: when asked (--revoke), or after a yes at the terminal.
284
+ const tty = (io.stdin || process.stdin).isTTY;
285
+ const revoke =
286
+ !!(url && key) &&
287
+ (o.revoke ||
288
+ (tty && !o.json && /^y(es)?$/i.test((await ask(io, 'Also revoke this key on the server, so it stops working everywhere? [y/N] ')).trim())));
289
+ let revoked = false;
290
+ if (revoke)
291
+ try {
292
+ await api().request('DELETE', '/me/key');
293
+ revoked = true;
294
+ } catch (e) {
295
+ // Already revoked or expired: nothing left to do on the server.
296
+ if (!(e instanceof ApiError && e.status === 401)) throw e;
297
+ revoked = true;
298
+ }
299
+ clearConfig(env);
300
+ if (o.json) json({ ok: true, revoked });
301
+ else
302
+ print(
303
+ `Removed ${configPath(env)}.${revoked ? ' The key is revoked.' : ' The key still works until you revoke it in Settings → API (logout --revoke does both).'}`,
304
+ );
305
+ return 0;
306
+ }
307
+ case 'whoami': {
308
+ const me = await api().get('/me');
309
+ if (o.json) json(me);
310
+ else
311
+ print(`${me.name}${me.kind === 'bot' ? ' (bot)' : ` <${me.email}>`} · ${me.role} in ${me.workspace || url} · ${me.key.scope} key “${me.key.name}”`);
312
+ return 0;
313
+ }
314
+ case 'ls': {
315
+ const a = api();
316
+ const project = o.project || env.SUDO_PROJECT;
317
+ const mine = o.mine || !project;
318
+ const status = o.status || (o.all ? undefined : 'open');
319
+ const n = Math.min(Math.max(Number(o.limit) || 50, 1), 500);
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)]);
322
+ if (o.json) {
323
+ json(tasks);
324
+ return 0;
325
+ }
326
+ if (!tasks.length) print(dim(mine ? 'Nothing assigned to you here.' : 'No tasks.'));
327
+ for (const t of tasks) print(row(t, names));
328
+ return 0;
329
+ }
330
+ case 'new': {
331
+ const title = need(args.join(' ').trim(), 'Give the task a title: sudo-cli new "Fix login bug" -p WEB');
332
+ const project = need(o.project || env.SUDO_PROJECT, 'Say which project: -p <project key> (or set SUDO_PROJECT).');
333
+ if (o.priority && !PRIORITIES.includes(o.priority)) throw new UsageError(`Priority is one of ${PRIORITIES.join(', ')}.`);
334
+ if (o.status && !STATUSES.includes(o.status)) throw new UsageError(`Status is one of ${STATUSES.join(', ')}.`);
335
+ const body = {
336
+ project,
337
+ title,
338
+ ...(o.due ? { due: parseDue(o.due) } : {}),
339
+ ...(o.assignee ? { assignee: o.assignee } : {}),
340
+ ...(o.priority ? { priority: o.priority } : {}),
341
+ ...(o.status ? { status: o.status } : {}),
342
+ ...(o.desc ? { description: o.desc } : {}),
343
+ };
344
+ // Safe to retry: the server replays the first response for the same Idempotency-Key.
345
+ const t = await api().post('/tasks', body, { idempotencyKey: randomUUID() });
346
+ if (o.json) json(t);
347
+ else print(`Created ${bold(t.key)} ${t.title}${t.due ? dim(` · due ${t.due.slice(0, 10)}`) : ''}\n${dim(t.url)}`);
348
+ return 0;
349
+ }
350
+ case 'done': {
351
+ need(args.length, 'Which task? sudo-cli done WEB-12');
352
+ const a = api();
353
+ const done = [];
354
+ for (const k of args) done.push(await a.post(`/tasks/${seg(k)}/complete`));
355
+ if (o.json) json(done.length === 1 ? done[0] : done);
356
+ else for (const t of done) print(`Done ${bold(t.key)} ${t.title}`);
357
+ return 0;
358
+ }
359
+ case 'assign': {
360
+ const [k, ...rest] = args;
361
+ const to = rest.join(' ').trim();
362
+ need(k && to, 'sudo-cli assign WEB-12 <me | name | email | none>');
363
+ const a = api();
364
+ const t = await a.patch(`/tasks/${seg(k)}`, { assignee: to === 'none' ? null : to });
365
+ if (o.json) {
366
+ json(t);
367
+ return 0;
368
+ }
369
+ const names = await who(a);
370
+ print(t.assignee ? `Assigned ${bold(t.key)} to ${names.get(t.assignee)}` : `Unassigned ${bold(t.key)}`);
371
+ return 0;
372
+ }
373
+ case 'show': {
374
+ const k = need(args[0], 'Which task? sudo-cli show WEB-12');
375
+ const a = api();
376
+ const [t, comments, names] = await Promise.all([a.get(`/tasks/${seg(k)}`), a.get(`/tasks/${seg(k)}/comments`), who(a)]);
377
+ if (o.json) {
378
+ json({ ...t, comments: comments.data });
379
+ return 0;
380
+ }
381
+ print(`${bold(t.key)} ${t.title}`);
382
+ print(dim(`${t.status} · ${t.priority} · ${names.get(t.assignee) || 'unassigned'}${t.due ? ` · due ${t.due.slice(0, 10)}` : ''}`));
383
+ if (t.description) print('\n' + t.description);
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
+ );
395
+ print('\n' + dim(t.url));
396
+ return 0;
397
+ }
398
+ case 'comment': {
399
+ const [k, ...rest] = args;
400
+ const text = rest.join(' ').trim();
401
+ need(k && text, 'sudo-cli comment WEB-12 "Looks good"');
402
+ const c = await api().post(`/tasks/${seg(k)}/comments`, { text }, { idempotencyKey: randomUUID() });
403
+ if (o.json) json(c);
404
+ else print(`Commented on ${bold(k.toUpperCase())}`);
405
+ return 0;
406
+ }
407
+ case 'projects': {
408
+ const ps = await api().all('/projects', o.archived ? { includeArchived: 'true' } : {});
409
+ if (o.json) {
410
+ json(ps);
411
+ return 0;
412
+ }
413
+ for (const p of ps)
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
+ );
417
+ return 0;
418
+ }
419
+ case 'open': {
420
+ const k = need(args[0], 'What to open? sudo-cli open WEB-12');
421
+ need(url, 'Not logged in. Run: sudo-cli login');
422
+ // Keys look like WEB-12 (a task) or WEB (a project); resolve through the API so typos fail here.
423
+ const target = /-\d+$/.test(k) ? (await api().get(`/tasks/${seg(k)}`)).url : (await api().get(`/projects/${seg(k)}`)).url;
424
+ // The link comes from the server and goes to xdg-open / open / explorer, which would run or
425
+ // open anything: only a page of the workspace itself is opened (or printed).
426
+ if (!sameOrigin(target, url)) {
427
+ err.write(`sudo-cli: the workspace answered with a link outside ${new URL(url).origin}; not opening it.\n`);
428
+ return 1;
429
+ }
430
+ if (o.json) {
431
+ json({ url: target });
432
+ return 0;
433
+ }
434
+ if (o.print || !out.isTTY) {
435
+ print(target);
436
+ return 0;
437
+ }
438
+ const bin = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
439
+ spawn(bin, [target], { stdio: 'ignore', detached: true })
440
+ .on('error', () => print(target))
441
+ .unref();
442
+ return 0;
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;
471
+ default:
472
+ throw new UsageError(`Unknown command “${cmd}”. Run sudo-cli --help.`);
473
+ }
474
+ } catch (e) {
475
+ if (e instanceof UsageError) {
476
+ err.write(`sudo-cli: ${e.message}\n`);
477
+ return 2;
478
+ }
479
+ if (e instanceof ApiError) {
480
+ // The message is the server's text: stripped of control characters like everything else it sends.
481
+ const msg = clean(e.message);
482
+ if (o.json) err.write(JSON.stringify(clean({ error: msg, code: e.code, ...(e.field ? { field: e.field } : {}) })) + '\n');
483
+ else err.write(`sudo-cli: ${msg}${e.status === 401 ? ' Run sudo-cli login.' : ''}\n`);
484
+ return 1;
485
+ }
486
+ throw e;
487
+ }
488
+ }
489
+
490
+ /* A line from the terminal (hidden for keys), or the next line of stdin when it's piped (read once,
491
+ so `printf 'url\nkey' | sudo-cli login` answers both questions). */
492
+ const piped = new WeakMap();
493
+ async function ask(io, question, { hidden = false } = {}) {
494
+ const input = io.stdin || process.stdin;
495
+ const output = io.stderr || process.stderr;
496
+ if (!input.isTTY) {
497
+ if (!piped.has(input)) {
498
+ let s = '';
499
+ for await (const ch of input) s += ch;
500
+ piped.set(input, s.split(/\r?\n/));
501
+ }
502
+ return piped.get(input).shift() || '';
503
+ }
504
+ const rl = readline.createInterface({ input, output, terminal: true });
505
+ if (hidden) rl._writeToOutput = s => output.write(s.startsWith(question) ? question : s.includes('\n') ? '\n' : '');
506
+ try {
507
+ return await new Promise(resolve => rl.question(question, resolve));
508
+ } finally {
509
+ rl.close();
510
+ }
511
+ }