@sudosapient/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,70 @@
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: 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
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
+ Due dates: `today`, `tomorrow`, a weekday (`friday`, `fri`; today counts), `next week`, `+3d`, `+2w`, or
64
+ `2026-11-20`. Set `SUDO_PROJECT=WEB` to skip `-p`. Exit codes: `0` ok, `1` the API refused (the
65
+ message says why; with `--json` it's `{ error, code }` on stderr), `2` a usage error.
66
+
67
+ ## License
68
+
69
+ UNLICENSED, all rights reserved. The package is public on npm so anyone can install and run it, but
70
+ 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.1.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.1.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,368 @@
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
+
11
+ // 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'];
15
+
16
+ export const HELP = `sudo-cli: Sudo Sapient from the terminal
17
+
18
+ Usage
19
+ sudo-cli login [--url <workspace url>] asks for the key (or pipe it in)
20
+ sudo-cli logout [--revoke] --revoke: also revoke the key on the server
21
+ sudo-cli whoami
22
+ sudo-cli tasks [list] [--mine] [-p <project>] [-s <status>] [--all] [-n <limit>]
23
+ sudo-cli tasks new "<title>" -p <project> [--due <date>] [-a <who>] [--priority <p>] [-d <text>]
24
+ sudo-cli tasks done <KEY>...
25
+ sudo-cli tasks assign <KEY> <who> who: me, a name, an email, or "none"
26
+ sudo-cli tasks show <KEY>
27
+ sudo-cli comment <KEY> "<text>"
28
+ sudo-cli projects [list]
29
+ sudo-cli open <KEY> opens a task or project in the browser (--print: just the URL)
30
+
31
+ Shortcuts: new, done, assign, show (= tasks …). Dates: today, tomorrow, friday, +3d, +2w, 2026-11-20.
32
+ Options: --json (machine-readable output), --url, --insecure (allow a plain http:// URL to another
33
+ machine; login remembers it). The key comes from login, else SUDO_API_KEY; --key works too, but it
34
+ lands in your shell history and the process list, so prefer the prompt or the variable.
35
+ Create a key in Settings → API. Config: ${'$'}XDG_CONFIG_HOME/sudo-sapient/config.json.`;
36
+
37
+ const OPTIONS = {
38
+ json: { type: 'boolean' },
39
+ url: { type: 'string' },
40
+ key: { type: 'string' },
41
+ insecure: { type: 'boolean' },
42
+ revoke: { type: 'boolean' },
43
+ project: { type: 'string', short: 'p' },
44
+ status: { type: 'string', short: 's' },
45
+ assignee: { type: 'string', short: 'a' },
46
+ due: { type: 'string' },
47
+ priority: { type: 'string' },
48
+ desc: { type: 'string', short: 'd' },
49
+ limit: { type: 'string', short: 'n' },
50
+ mine: { type: 'boolean' },
51
+ all: { type: 'boolean' },
52
+ print: { type: 'boolean' },
53
+ help: { type: 'boolean', short: 'h' },
54
+ version: { type: 'boolean', short: 'v' },
55
+ };
56
+
57
+ /* Everyday dates to YYYY-MM-DD, in local time. A weekday is its next occurrence (today counts). */
58
+ const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
59
+ export function parseDue(s, now = new Date()) {
60
+ const v = String(s).trim().toLowerCase();
61
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
62
+ const inDays = n => {
63
+ const d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + n);
64
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
65
+ };
66
+ if (v === 'today') return inDays(0);
67
+ if (v === 'tomorrow') return inDays(1);
68
+ const rel = /^\+?(\d{1,3})\s*(d|w)$/.exec(v);
69
+ if (rel) return inDays(Number(rel[1]) * (rel[2] === 'w' ? 7 : 1));
70
+ if (v === 'next week') return inDays((8 - now.getDay()) % 7 || 7);
71
+ const day = DAYS.findIndex(n => n === v || n.slice(0, 3) === v);
72
+ if (day >= 0) return inDays((day - now.getDay() + 7) % 7);
73
+ throw new UsageError(`Can't read the date “${s}”. Try today, tomorrow, friday, +3d or 2026-11-20.`);
74
+ }
75
+
76
+ class UsageError extends Error {}
77
+
78
+ /* `link` is on the same site as the workspace (scheme, host and port), so nothing the server sends
79
+ can make the CLI open some other page or program. */
80
+ export function sameOrigin(link, workspace) {
81
+ try {
82
+ return new URL(link).origin === new URL(workspace).origin;
83
+ } catch {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ /* Text from the workspace is printed to a terminal: drop control characters (escape sequences could
89
+ rewrite the screen or the clipboard), keeping newlines and tabs. */
90
+ // eslint-disable-next-line no-control-regex -- matching control characters is the point
91
+ const CONTROL = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g;
92
+ export const clean = v =>
93
+ typeof v === 'string'
94
+ ? v.replace(CONTROL, '')
95
+ : Array.isArray(v)
96
+ ? v.map(clean)
97
+ : v && typeof v === 'object'
98
+ ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clean(x)]))
99
+ : v;
100
+
101
+ export async function run(argv, io = {}) {
102
+ const env = io.env || process.env;
103
+ const out = io.stdout || process.stdout;
104
+ const err = io.stderr || process.stderr;
105
+ const print = s => out.write(s + '\n');
106
+ const color = !env.NO_COLOR && out.isTTY;
107
+ const dim = s => (color ? `\x1b[2m${s}\x1b[0m` : s);
108
+ const bold = s => (color ? `\x1b[1m${s}\x1b[0m` : s);
109
+
110
+ let o, pos;
111
+ try {
112
+ ({ values: o, positionals: pos } = parseArgs({ args: argv, options: OPTIONS, allowPositionals: true, strict: true }));
113
+ } catch (e) {
114
+ err.write(`sudo-cli: ${e.message}\nRun sudo-cli --help for usage.\n`);
115
+ return 2;
116
+ }
117
+ if (o.version || o.help || !pos.length) {
118
+ print(o.version ? VERSION : HELP);
119
+ return o.version || o.help ? 0 : 2;
120
+ }
121
+
122
+ // `tasks new …` and `new …` are the same command.
123
+ let [cmd, ...args] = pos;
124
+ if (cmd === 'tasks' || cmd === 'task') {
125
+ const sub = args[0];
126
+ [cmd, ...args] = ['new', 'done', 'assign', 'show', 'list'].includes(sub) ? [sub === 'list' ? 'ls' : sub, ...args.slice(1)] : ['ls', ...args];
127
+ }
128
+ if (cmd === 'projects' || cmd === 'project') [cmd, args] = ['projects', args[0] === 'list' ? args.slice(1) : args];
129
+
130
+ const cfg = loadConfig(env);
131
+ const url = (o.url || cfg.url || '').replace(/\/+$/, '');
132
+ const key = o.key || cfg.key;
133
+ if (o.key)
134
+ 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');
135
+ // The key goes in a header on every request: never in the clear to another machine unless asked.
136
+ const insecureOk = o.insecure || (!o.url && cfg.insecure);
137
+ const needSecure = (u, ok = insecureOk) => {
138
+ if (plainHttpRemote(u) && !ok)
139
+ 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
+ };
141
+ const api = () => {
142
+ if (!url || !key) throw new UsageError('Not logged in. Run: sudo-cli login --url https://your-workspace');
143
+ needSecure(url);
144
+ const a = createClient({ url, key, client: 'CLI', fetch: io.fetch });
145
+ // JSON output escapes control characters itself; human output gets them stripped.
146
+ if (o.json) return a;
147
+ const wrap =
148
+ f =>
149
+ async (...args) =>
150
+ clean(await f(...args));
151
+ return { ...a, get: wrap(a.get), post: wrap(a.post), patch: wrap(a.patch), all: wrap(a.all) };
152
+ };
153
+ const json = v => print(JSON.stringify(v, null, 2));
154
+ const who = async a => new Map((await a.all('/members')).map(m => [m.id, m.name]));
155
+ const pad = (s, n) => String(s ?? '').padEnd(n);
156
+ const row = (t, names) =>
157
+ `${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}`;
158
+ const need = (v, msg) => {
159
+ if (!v) throw new UsageError(msg);
160
+ return v;
161
+ };
162
+
163
+ try {
164
+ switch (cmd) {
165
+ case 'login': {
166
+ const u = (o.url || (await ask(io, `Workspace URL${cfg.url ? ` (${cfg.url})` : ''}: `)) || cfg.url || '').trim().replace(/\/+$/, '');
167
+ need(/^https?:\/\//.test(u), 'Give the workspace URL, e.g. https://sudo.example.com');
168
+ // Logging in again asks again: the saved consent was for the saved URL.
169
+ needSecure(u, o.insecure);
170
+ const k = (o.key || (await ask(io, 'API key (Settings → API): ', { hidden: true }))).trim();
171
+ 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'));
173
+ const file = saveConfig({ url: u, key: k, ...(o.insecure && plainHttpRemote(u) ? { insecure: true } : {}) }, env);
174
+ if (o.json) json({ ok: true, user: me, config: file });
175
+ else print(`Logged in to ${me.workspace || u} as ${me.name} (${me.key.scope} key “${me.key.name}”). Saved to ${file}`);
176
+ return 0;
177
+ }
178
+ case 'logout': {
179
+ // Revoke the key on the server too: when asked (--revoke), or after a yes at the terminal.
180
+ const tty = (io.stdin || process.stdin).isTTY;
181
+ const revoke =
182
+ !!(url && key) &&
183
+ (o.revoke ||
184
+ (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())));
185
+ let revoked = false;
186
+ if (revoke)
187
+ try {
188
+ await api().request('DELETE', '/me/key');
189
+ revoked = true;
190
+ } catch (e) {
191
+ // Already revoked or expired: nothing left to do on the server.
192
+ if (!(e instanceof ApiError && e.status === 401)) throw e;
193
+ revoked = true;
194
+ }
195
+ clearConfig(env);
196
+ if (o.json) json({ ok: true, revoked });
197
+ else
198
+ print(
199
+ `Removed ${configPath(env)}.${revoked ? ' The key is revoked.' : ' The key still works until you revoke it in Settings → API (logout --revoke does both).'}`,
200
+ );
201
+ return 0;
202
+ }
203
+ case 'whoami': {
204
+ const me = await api().get('/me');
205
+ if (o.json) json(me);
206
+ else
207
+ print(`${me.name}${me.kind === 'bot' ? ' (bot)' : ` <${me.email}>`} · ${me.role} in ${me.workspace || url} · ${me.key.scope} key “${me.key.name}”`);
208
+ return 0;
209
+ }
210
+ case 'ls': {
211
+ const a = api();
212
+ const project = o.project || env.SUDO_PROJECT;
213
+ const mine = o.mine || !project;
214
+ const status = o.status || (o.all ? undefined : 'open');
215
+ 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)]);
217
+ if (o.json) {
218
+ json(tasks);
219
+ return 0;
220
+ }
221
+ if (!tasks.length) print(dim(mine ? 'Nothing assigned to you here.' : 'No tasks.'));
222
+ for (const t of tasks) print(row(t, names));
223
+ return 0;
224
+ }
225
+ case 'new': {
226
+ const title = need(args.join(' ').trim(), 'Give the task a title: sudo-cli new "Fix login bug" -p WEB');
227
+ const project = need(o.project || env.SUDO_PROJECT, 'Say which project: -p <project key> (or set SUDO_PROJECT).');
228
+ if (o.priority && !PRIORITIES.includes(o.priority)) throw new UsageError(`Priority is one of ${PRIORITIES.join(', ')}.`);
229
+ if (o.status && !STATUSES.includes(o.status)) throw new UsageError(`Status is one of ${STATUSES.join(', ')}.`);
230
+ const body = {
231
+ project,
232
+ title,
233
+ ...(o.due ? { due: parseDue(o.due) } : {}),
234
+ ...(o.assignee ? { assignee: o.assignee } : {}),
235
+ ...(o.priority ? { priority: o.priority } : {}),
236
+ ...(o.status ? { status: o.status } : {}),
237
+ ...(o.desc ? { description: o.desc } : {}),
238
+ };
239
+ // Safe to retry: the server replays the first response for the same Idempotency-Key.
240
+ const t = await api().post('/tasks', body, { idempotencyKey: randomUUID() });
241
+ if (o.json) json(t);
242
+ else print(`Created ${bold(t.key)} ${t.title}${t.due ? dim(` · due ${t.due.slice(0, 10)}`) : ''}\n${dim(t.url)}`);
243
+ return 0;
244
+ }
245
+ case 'done': {
246
+ need(args.length, 'Which task? sudo-cli done WEB-12');
247
+ const a = api();
248
+ const done = [];
249
+ for (const k of args) done.push(await a.post(`/tasks/${seg(k)}/complete`));
250
+ if (o.json) json(done.length === 1 ? done[0] : done);
251
+ else for (const t of done) print(`Done ${bold(t.key)} ${t.title}`);
252
+ return 0;
253
+ }
254
+ case 'assign': {
255
+ const [k, ...rest] = args;
256
+ const to = rest.join(' ').trim();
257
+ need(k && to, 'sudo-cli assign WEB-12 <me | name | email | none>');
258
+ const a = api();
259
+ const t = await a.patch(`/tasks/${seg(k)}`, { assignee: to === 'none' ? null : to });
260
+ if (o.json) {
261
+ json(t);
262
+ return 0;
263
+ }
264
+ const names = await who(a);
265
+ print(t.assignee ? `Assigned ${bold(t.key)} to ${names.get(t.assignee)}` : `Unassigned ${bold(t.key)}`);
266
+ return 0;
267
+ }
268
+ case 'show': {
269
+ const k = need(args[0], 'Which task? sudo-cli show WEB-12');
270
+ const a = api();
271
+ const [t, comments, names] = await Promise.all([a.get(`/tasks/${seg(k)}`), a.get(`/tasks/${seg(k)}/comments`), who(a)]);
272
+ if (o.json) {
273
+ json({ ...t, comments: comments.data });
274
+ return 0;
275
+ }
276
+ print(`${bold(t.key)} ${t.title}`);
277
+ print(dim(`${t.status} · ${t.priority} · ${names.get(t.assignee) || 'unassigned'}${t.due ? ` · due ${t.due.slice(0, 10)}` : ''}`));
278
+ 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}`);
281
+ print('\n' + dim(t.url));
282
+ return 0;
283
+ }
284
+ case 'comment': {
285
+ const [k, ...rest] = args;
286
+ const text = rest.join(' ').trim();
287
+ need(k && text, 'sudo-cli comment WEB-12 "Looks good"');
288
+ const c = await api().post(`/tasks/${seg(k)}/comments`, { text }, { idempotencyKey: randomUUID() });
289
+ if (o.json) json(c);
290
+ else print(`Commented on ${bold(k.toUpperCase())}`);
291
+ return 0;
292
+ }
293
+ case 'projects': {
294
+ const ps = await api().all('/projects');
295
+ if (o.json) {
296
+ json(ps);
297
+ return 0;
298
+ }
299
+ 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' : ''}`)}`);
301
+ return 0;
302
+ }
303
+ case 'open': {
304
+ const k = need(args[0], 'What to open? sudo-cli open WEB-12');
305
+ need(url, 'Not logged in. Run: sudo-cli login');
306
+ // Keys look like WEB-12 (a task) or WEB (a project); resolve through the API so typos fail here.
307
+ const target = /-\d+$/.test(k) ? (await api().get(`/tasks/${seg(k)}`)).url : (await api().get(`/projects/${seg(k)}`)).url;
308
+ // The link comes from the server and goes to xdg-open / open / explorer, which would run or
309
+ // open anything: only a page of the workspace itself is opened (or printed).
310
+ if (!sameOrigin(target, url)) {
311
+ err.write(`sudo-cli: the workspace answered with a link outside ${new URL(url).origin}; not opening it.\n`);
312
+ return 1;
313
+ }
314
+ if (o.json) {
315
+ json({ url: target });
316
+ return 0;
317
+ }
318
+ if (o.print || !out.isTTY) {
319
+ print(target);
320
+ return 0;
321
+ }
322
+ const bin = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
323
+ spawn(bin, [target], { stdio: 'ignore', detached: true })
324
+ .on('error', () => print(target))
325
+ .unref();
326
+ return 0;
327
+ }
328
+ default:
329
+ throw new UsageError(`Unknown command “${cmd}”. Run sudo-cli --help.`);
330
+ }
331
+ } catch (e) {
332
+ if (e instanceof UsageError) {
333
+ err.write(`sudo-cli: ${e.message}\n`);
334
+ return 2;
335
+ }
336
+ if (e instanceof ApiError) {
337
+ // The message is the server's text: stripped of control characters like everything else it sends.
338
+ const msg = clean(e.message);
339
+ if (o.json) err.write(JSON.stringify(clean({ error: msg, code: e.code, ...(e.field ? { field: e.field } : {}) })) + '\n');
340
+ else err.write(`sudo-cli: ${msg}${e.status === 401 ? ' Run sudo-cli login.' : ''}\n`);
341
+ return 1;
342
+ }
343
+ throw e;
344
+ }
345
+ }
346
+
347
+ /* A line from the terminal (hidden for keys), or the next line of stdin when it's piped (read once,
348
+ so `printf 'url\nkey' | sudo-cli login` answers both questions). */
349
+ const piped = new WeakMap();
350
+ async function ask(io, question, { hidden = false } = {}) {
351
+ const input = io.stdin || process.stdin;
352
+ const output = io.stderr || process.stderr;
353
+ if (!input.isTTY) {
354
+ if (!piped.has(input)) {
355
+ let s = '';
356
+ for await (const ch of input) s += ch;
357
+ piped.set(input, s.split(/\r?\n/));
358
+ }
359
+ return piped.get(input).shift() || '';
360
+ }
361
+ const rl = readline.createInterface({ input, output, terminal: true });
362
+ if (hidden) rl._writeToOutput = s => output.write(s.startsWith(question) ? question : s.includes('\n') ? '\n' : '');
363
+ try {
364
+ return await new Promise(resolve => rl.question(question, resolve));
365
+ } finally {
366
+ rl.close();
367
+ }
368
+ }