@zumino/cli 2.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.
@@ -0,0 +1,345 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ import { api, VERSION } from "../client.js";
4
+ import { configPath, readConfig, resolveContext, writeConfig } from "../config.js";
5
+ import { CliError } from "../errors.js";
6
+ import { bold, dim, json, note, out, table, safeText } from "../output.js";
7
+
8
+ /*
9
+ * Credentials, and the one thing this command cannot do.
10
+ *
11
+ * **It cannot mint a token.** `DOMAIN.md` puts credentials under governance: a
12
+ * token authenticates *as its owner*, so a token that could mint tokens turns a
13
+ * leak into permanent access that revoking the leaked one does not undo. A
14
+ * person creates one in a browser they are signed in to; this reads it, checks
15
+ * it, and remembers where it belongs.
16
+ */
17
+
18
+ /**
19
+ * The prompts `auth login` needs, and who is allowed to see the answers.
20
+ *
21
+ * The token is a long-lived credential that authenticates as its owner across
22
+ * every workspace they belong to, and the prompt is the promoted path — README,
23
+ * `/docs/api` and the generated installer all end by telling a new user to paste
24
+ * one here. Echoed, it lands in scrollback, tmux buffers, `script` recordings
25
+ * and shared sessions. The account name, by contrast, *should* echo.
26
+ *
27
+ * So the two cases are split by where the echo comes from:
28
+ *
29
+ * - **a TTY** echoes in the terminal driver, which no library flag reaches. Raw
30
+ * mode turns it off, so the secret is read that way and the name gets an
31
+ * ordinary interface afterwards. An earlier attempt overrode
32
+ * `rl._writeToOutput`, which does not exist on a `node:readline/promises`
33
+ * interface — that is the callback API's alias — so it created a property
34
+ * nothing called and echoed the token behind a docstring claiming otherwise.
35
+ * - **a pipe** has nothing echoing it, so one `terminal: false` interface reads
36
+ * both lines and writes nothing back. It has to be *one*: iterating stdin
37
+ * directly consumed the whole buffer and left a later interface hanging.
38
+ */
39
+ function prompter() {
40
+ const stdin = process.stdin;
41
+ let lines = null;
42
+ let next = 0;
43
+
44
+ // Piped input is read once, whole, and served line by line. Going through a
45
+ // readline interface per prompt did not survive both answers arriving in one
46
+ // chunk — the stream ended, the interface closed, and the second question
47
+ // threw `readline was closed`. Iterating stdin per prompt was worse: the first
48
+ // read consumed the buffer and left the second hanging.
49
+ async function pipedLines() {
50
+ if (lines) return lines;
51
+ let data = "";
52
+ for await (const chunk of stdin) data += chunk.toString("utf8");
53
+ lines = data.split(/\r?\n/);
54
+ return lines;
55
+ }
56
+
57
+ const nextPiped = async (promptText) => {
58
+ process.stderr.write(promptText + "\n");
59
+ return (await pipedLines())[next++] ?? "";
60
+ };
61
+
62
+ return {
63
+ /** The token. Never echoed. */
64
+ secret: (promptText) =>
65
+ stdin.isTTY ? readRaw(promptText) : nextPiped(promptText),
66
+
67
+ /** The account name, which should echo on a terminal. */
68
+ async line(promptText) {
69
+ if (!stdin.isTTY) return nextPiped(promptText);
70
+ const rl = createInterface({ input: stdin, output: process.stderr });
71
+ try {
72
+ return await rl.question(promptText);
73
+ } finally {
74
+ rl.close();
75
+ }
76
+ },
77
+
78
+ close() {},
79
+ };
80
+ }
81
+
82
+ /**
83
+ * Read one line from a TTY with the driver's echo turned off.
84
+ *
85
+ * **Event listeners, not `for await`.** Node's async iterator destroys the
86
+ * stream when the loop is left early, and `destroyOnReturn` defaults to true —
87
+ * so reading the token with `for await … break` destroyed `process.stdin`, and
88
+ * the account-name prompt built over it afterwards never resolved. The token was
89
+ * verified against the server and then thrown away with no error, on the exact
90
+ * path the installer and the docs page promote.
91
+ *
92
+ * The stream is a parameter so this is testable without a pty; `login` passes
93
+ * `process.stdin`.
94
+ */
95
+ function readRaw(promptText, stdin = process.stdin) {
96
+ process.stderr.write(promptText);
97
+ const wasRaw = stdin.isRaw;
98
+ if (stdin.isTTY) stdin.setRawMode(true);
99
+ stdin.resume();
100
+
101
+ return new Promise((resolve, reject) => {
102
+ let input = "";
103
+
104
+ // `rest` is put back only after the listeners are gone — unshifting while
105
+ // still subscribed re-delivers it to this same handler, which appended the
106
+ // next answer onto the token.
107
+ const done = (fn, value, rest) => {
108
+ stdin.removeListener("data", onData);
109
+ stdin.removeListener("error", onError);
110
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
111
+ stdin.pause();
112
+ if (rest) stdin.unshift(Buffer.from(rest, "utf8"));
113
+ process.stderr.write("\n");
114
+ fn(value);
115
+ };
116
+
117
+ const onError = (err) => done(reject, err);
118
+
119
+ const onData = (chunk) => {
120
+ const text = chunk.toString("utf8");
121
+ for (let i = 0; i < text.length; i++) {
122
+ const ch = text[i];
123
+ if (ch === "\r" || ch === "\n") {
124
+ // Whatever followed the newline belongs to the next prompt. A terminal
125
+ // or a pty can deliver both answers in one chunk, and dropping the
126
+ // remainder left the next prompt waiting for input already read and
127
+ // discarded — which hung `auth login` just as surely as destroying the
128
+ // stream did.
129
+ return done(resolve, input, text.slice(i + 1).replace(/^\n/, ""));
130
+ }
131
+ // Raw mode delivers no signal, so Ctrl-C is honoured here or the prompt
132
+ // cannot be escaped.
133
+ if (ch === "\u0003") return done(reject, new CliError("Cancelled."));
134
+ if (ch === "\u007F" || ch === "\b") {
135
+ input = input.slice(0, -1);
136
+ continue;
137
+ }
138
+ if (ch >= " ") input += ch;
139
+ }
140
+ };
141
+
142
+ stdin.on("data", onData);
143
+ stdin.on("error", onError);
144
+ });
145
+ }
146
+
147
+ export const __testables = { readRaw };
148
+
149
+ const SUB = { login, status, list, logout };
150
+
151
+ export async function run(args, flags) {
152
+ const [sub = "status", ...rest] = args;
153
+ const fn = SUB[sub];
154
+ if (!fn) {
155
+ throw new CliError(`zumino auth: unknown subcommand "${sub}".`, {
156
+ hint: `One of: ${Object.keys(SUB).join(", ")}`,
157
+ });
158
+ }
159
+ return fn(rest, flags);
160
+ }
161
+
162
+ async function login(args, flags) {
163
+ // `ZUMINO_URL` belongs here too: it is read on all six rungs in config.js, the
164
+ // README documents it as the host origin, and `/install.sh` — generated per
165
+ // instance — tells every self-hosted user to run the bare `zumino auth login`.
166
+ // Without it, following those instructions sent a live token issued by a
167
+ // self-hosted instance to zumino.cc before anything was validated.
168
+ const host = (
169
+ flags.host ??
170
+ args[0] ??
171
+ process.env.ZUMINO_URL ??
172
+ "https://zumino.cc"
173
+ ).replace(/\/+$/, "");
174
+
175
+ // The readline interface is created *after* the token is read, not before.
176
+ // Both consume stdin, and an interface attached while `askSecret` iterates the
177
+ // same stream aborts it — the secret prompt failed outright with ABORT_ERR.
178
+ // So: token first, by whatever means, then a prompt for the name only if no
179
+ // flag answered it.
180
+ const ask = prompter();
181
+
182
+ try {
183
+ if (!flags.token) {
184
+ note(`\nCreate a personal access token at:\n ${bold(`${host}/app/account`)}\n`);
185
+ }
186
+ const token = (flags.token ?? (await ask.secret("Paste it here: "))).trim();
187
+ if (!token) throw new CliError("No token given.");
188
+
189
+ // Verify before storing. A token that does not work is worse in a config
190
+ // file than absent: the failure surfaces later, somewhere else.
191
+ const me = await api({ host, token }, "GET", "/me");
192
+ // Sanitised here because it reaches four sinks: the note below, the config
193
+ // file (where JSON escapes it and JSON.parse restores it live on every later
194
+ // `auth list`), that list's table — whose widths count escape bytes as
195
+ // columns — and the collision hint. `status()` already did this; these did
196
+ // not.
197
+ const who = safeText(me?.user?.email ?? me?.user?.name) || "?";
198
+ const suggested =
199
+ flags.account ?? new URL(host).hostname.split(".")[0].replace(/[^a-z0-9-]/gi, "") ?? "default";
200
+ let name = flags.account;
201
+ if (!name) {
202
+ // Both prompts are skipped when the flag that answers them is present, so
203
+ // `--token … --account …` completes with no terminal at all.
204
+ name = (await ask.line(`Name for this account [${suggested}]: `)).trim() || suggested;
205
+ }
206
+
207
+ const cfg = readConfig();
208
+
209
+ // The suggested name is the host's first label, so `zumino.cc` and
210
+ // `zumino.customer.example` both suggest `zumino`. Overwriting silently
211
+ // discarded the first credential while every repo mapping kept pointing at
212
+ // that name — and on one host the redirect guard sees no mismatch, so those
213
+ // repositories then wrote as the second identity. That is the identity
214
+ // switch this design exists to prevent, arriving through the back door.
215
+ const existing = cfg.accounts[name];
216
+ if (existing && !flags.yes) {
217
+ throw new CliError(`An account named "${name}" already exists.`, {
218
+ hint:
219
+ `It points at ${safeText(existing.host)}${existing.user ? ` as ${safeText(existing.user)}` : ""}. ` +
220
+ `Choose another name with --account, or pass --yes to replace it ` +
221
+ `(every repo mapped to "${name}" would then use the new credential).`,
222
+ });
223
+ }
224
+
225
+ cfg.accounts[name] = { host, token, user: who, readOnly: me?.token?.readOnly ?? false };
226
+ writeConfig(cfg);
227
+
228
+ note(`\nStored account ${bold(name)} in ${configPath()} (0600).`);
229
+ note(`Signed in as ${who}${me?.token?.readOnly ? " (read-only)" : ""}.`);
230
+ note(
231
+ dim(
232
+ `\nNothing is "current" — that is deliberate, so two agents cannot change\n` +
233
+ `each other's identity. Tie this repo to the account with: zumino init`,
234
+ ),
235
+ );
236
+ return 0;
237
+ } finally {
238
+ ask.close();
239
+ }
240
+ }
241
+
242
+ /**
243
+ * Which rung of the resolution chain answered, and who that makes you.
244
+ *
245
+ * The `source` line is the point of this command. "Which account am I about to
246
+ * write as" has to be a question with a visible answer, or the absence of a
247
+ * stored current account just moves the confusion somewhere else.
248
+ */
249
+ async function status(args, flags) {
250
+ const ctx = resolveContext(flags);
251
+ let me = null;
252
+ try {
253
+ me = await api(ctx, "GET", "/me");
254
+ } catch (err) {
255
+ // `err.exitCode`, not a literal 1. The exit codes are the contract a hook or
256
+ // an agent branches on, and `auth status` is the first command the skill
257
+ // tells every session to run — so flattening a 3 here meant the one call an
258
+ // automated wrapper makes to check its footing was the one that misreported
259
+ // it, while every other command on the same machine exited 3 correctly.
260
+ const code = err.exitCode ?? 1;
261
+ if (flags.json) {
262
+ json({ ...redact(ctx), ok: false, error: err.message, hint: err.hint, exitCode: code });
263
+ return code;
264
+ }
265
+ out(`token: ${ctx.source}`);
266
+ out(`host: ${ctx.host}`);
267
+ note(`\nzumino: ${err.message}`);
268
+ if (err.hint) note(` ${err.hint}`);
269
+ return code;
270
+ }
271
+
272
+ if (flags.json) {
273
+ json({ ...redact(ctx), ok: true, user: me?.user, token: me?.token, cli: VERSION });
274
+ return 0;
275
+ }
276
+ // `readOnly` is checked up front and printed here rather than being
277
+ // discovered through a 403 halfway through a run of writes.
278
+ const readOnly = me?.token?.readOnly ? " (read-only)" : "";
279
+ out(`token: ${ctx.source}${readOnly}`);
280
+ out(`host: ${ctx.host}`);
281
+ out(`acting as: ${safeText(me?.user?.email ?? me?.user?.name) || "?"}`);
282
+ if (ctx.workspace) out(`workspace: ${ctx.workspace}`);
283
+ if (ctx.project) out(`project: ${ctx.project}`);
284
+ out(dim(`cli: ${VERSION}`));
285
+ return 0;
286
+ }
287
+
288
+ function redact(ctx) {
289
+ return { source: ctx.source, host: ctx.host, project: ctx.project, workspace: ctx.workspace };
290
+ }
291
+
292
+ async function list(args, flags) {
293
+ const cfg = readConfig();
294
+ const names = Object.keys(cfg.accounts);
295
+ if (flags.json) {
296
+ json({
297
+ accounts: Object.fromEntries(
298
+ names.map((n) => [n, { host: cfg.accounts[n].host, user: cfg.accounts[n].user }]),
299
+ ),
300
+ repos: cfg.repos,
301
+ });
302
+ return 0;
303
+ }
304
+ if (names.length === 0) {
305
+ out("No accounts configured.");
306
+ note(dim(" Add one with: zumino auth login"));
307
+ return 0;
308
+ }
309
+ table(
310
+ names.map((n) => [n, safeText(cfg.accounts[n].host), safeText(cfg.accounts[n].user)]),
311
+ { head: ["ACCOUNT", "HOST", "USER"] },
312
+ );
313
+ const repos = Object.entries(cfg.repos ?? {});
314
+ if (repos.length) {
315
+ out("");
316
+ table(repos.map(([k, v]) => [safeText(k), safeText(v.account), safeText(v.project)]), {
317
+ head: ["REPO", "ACCOUNT", "PROJECT"],
318
+ });
319
+ }
320
+ return 0;
321
+ }
322
+
323
+ async function logout(args, flags) {
324
+ const name = args[0] ?? flags.account;
325
+ if (!name) {
326
+ throw new CliError("Which account? zumino auth logout <name>", {
327
+ hint: "List them with: zumino auth list",
328
+ });
329
+ }
330
+ const cfg = readConfig();
331
+ if (!cfg.accounts[name]) throw new CliError(`No account named "${name}".`);
332
+ delete cfg.accounts[name];
333
+ for (const [repo, m] of Object.entries(cfg.repos)) {
334
+ if (m.account === name) delete cfg.repos[repo];
335
+ }
336
+ writeConfig(cfg);
337
+ out(`Removed ${name}.`);
338
+ note(
339
+ dim(
340
+ " The token still exists on the server — this only forgot it here.\n" +
341
+ " Revoke it at /app/account.",
342
+ ),
343
+ );
344
+ return 0;
345
+ }
@@ -0,0 +1,55 @@
1
+ import { api } from "../client.js";
2
+ import { resolveContext } from "../config.js";
3
+ import { CliError } from "../errors.js";
4
+ import { json, note, out, safeText } from "../output.js";
5
+ import { resolveItem } from "../items.js";
6
+
7
+ /**
8
+ * `zumino context <CODE>` — the whole brief in one call.
9
+ *
10
+ * The endpoint this wraps assembles the epic, the description, the plan, the
11
+ * acceptance criteria, the blockers and the requests it answers into one
12
+ * markdown document. The reason to prefer it over six reads is not round trips:
13
+ * it is that six reads leave the caller to lay them out, and an agent that
14
+ * assembles its own brief assembles a *plausible* one rather than the real one.
15
+ */
16
+ export async function run(args, flags) {
17
+ const [ref] = args;
18
+ if (!ref) {
19
+ throw new CliError("Which task? zumino context <CODE>", {
20
+ hint: "Find one with: zumino queue",
21
+ });
22
+ }
23
+ const ctx = resolveContext(flags);
24
+ const { path, kind } = await resolveItem(ctx, ref, { kind: "task" });
25
+
26
+ if (kind !== "task") {
27
+ throw new CliError(`${ref} is a ${kind}, and only tasks carry a brief.`, {
28
+ hint: `Read it with: zumino api GET ${path}`,
29
+ });
30
+ }
31
+
32
+ const res = await api(ctx, "GET", `${path}/context`, {
33
+ query: { activity: flags.activity ? "true" : undefined },
34
+ });
35
+
36
+ if (flags.json) {
37
+ json(res);
38
+ return 0;
39
+ }
40
+
41
+ // The brief is destined for a prompt or a terminal; neither wants live
42
+ // escape sequences from somebody else's task description.
43
+ out(safeText(res.markdown));
44
+
45
+ // Readiness goes to stderr: the brief is the answer and belongs in the pipe,
46
+ // while "this spec is below the bar" is a note to whoever is reading.
47
+ const r = res.readiness;
48
+ if (r && !r.meetsBar) {
49
+ note(
50
+ `\nzumino: this spec is below the bar — missing ${r.missing.join(", ")}.\n` +
51
+ ` It will not appear in zumino queue until it is shaped.`,
52
+ );
53
+ }
54
+ return 0;
55
+ }
@@ -0,0 +1,85 @@
1
+ import { api } from "../client.js";
2
+ import { resolveContext } from "../config.js";
3
+ import { CliError } from "../errors.js";
4
+ import { clip, json, out, pick, table, positiveInt, safeText, givenFlag } from "../output.js";
5
+ import { projectPath, resolveItem } from "../items.js";
6
+
7
+ /*
8
+ * Epics group tasks, and are not tasks.
9
+ *
10
+ * Grouping is one level deep (`docs/decisions/0001`) and there are no sub-tasks,
11
+ * so there is deliberately nothing here that nests an epic inside anything.
12
+ */
13
+
14
+ const SUB = { create, status, list, show };
15
+
16
+ export async function run(args, flags) {
17
+ const [sub, ...rest] = args;
18
+ const fn = SUB[sub];
19
+ if (!fn) {
20
+ throw new CliError(`zumino epic: unknown subcommand "${sub ?? ""}".`, {
21
+ hint: `One of: ${Object.keys(SUB).join(", ")}`,
22
+ });
23
+ }
24
+ return fn(rest, flags);
25
+ }
26
+
27
+ async function create(args, flags) {
28
+ const ctx = resolveContext(flags);
29
+ const title = flags.title ?? args.join(" ").trim();
30
+ if (!title) throw new CliError("An epic needs a title.", { hint: 'zumino epic create --title "…"' });
31
+ const base = await projectPath(ctx);
32
+ const body = { title };
33
+ if (givenFlag(flags.description) !== undefined) body.description = flags.description;
34
+ const res = pick(await api(ctx, "POST", `${base}/epics`, { body }), "epic");
35
+ if (flags.json) return json(res), 0;
36
+ // An epic shape publishes neither `code` nor `ref` — just a number. `E<n>` is
37
+ // a real address (see `resolveItem`), so what is printed can be fed straight
38
+ // back to `zumino epic show`; the full `KEY-E<n>` form works too and is what
39
+ // `zumino find --kind epic` shows.
40
+ out(`E${res.number} ${safeText(res.title)}`);
41
+ return 0;
42
+ }
43
+
44
+ async function status(args, flags) {
45
+ const [ref, value] = args;
46
+ if (!ref || !value) throw new CliError("zumino epic status <CODE|N> <open|closed>");
47
+ const ctx = resolveContext(flags);
48
+ const { path } = await resolveItem(ctx, ref, { kind: "epic" });
49
+ const res = pick(await api(ctx, "PATCH", path, { body: { status: value } }), "epic");
50
+ if (flags.json) return json(res), 0;
51
+ out(`${ref} ${res.status}`);
52
+ return 0;
53
+ }
54
+
55
+ async function list(args, flags) {
56
+ const ctx = resolveContext(flags);
57
+ const base = await projectPath(ctx);
58
+ const res = await api(ctx, "GET", `${base}/epics`, { query: { limit: positiveInt(flags.limit, "limit") } });
59
+ const epics = res?.epics ?? [];
60
+ if (flags.json) return json(epics), 0;
61
+ if (epics.length === 0) return out("No epics."), 0;
62
+ table(
63
+ epics.map((e) => [
64
+ `E${e.number}`,
65
+ e.status ?? "",
66
+ `${e.progress?.closed ?? 0}/${e.progress?.total ?? 0}`,
67
+ clip(e.title),
68
+ ]),
69
+ { head: ["EPIC", "STATUS", "DONE", "TITLE"] },
70
+ );
71
+ return 0;
72
+ }
73
+
74
+ async function show(args, flags) {
75
+ const [ref] = args;
76
+ if (!ref) throw new CliError("zumino epic show <CODE|N>");
77
+ const ctx = resolveContext(flags);
78
+ const { path } = await resolveItem(ctx, ref, { kind: "epic" });
79
+ const res = pick(await api(ctx, "GET", path), "epic");
80
+ if (flags.json) return json(res), 0;
81
+ out(`E${res.number} ${safeText(res.title)}`);
82
+ out(`status ${res.status} ${res.progress?.closed ?? 0}/${res.progress?.total ?? 0} done`);
83
+ if (res.description) out(`\n${safeText(res.description)}`);
84
+ return 0;
85
+ }
@@ -0,0 +1,83 @@
1
+ import { api } from "../client.js";
2
+ import { resolveContext } from "../config.js";
3
+ import { CliError } from "../errors.js";
4
+
5
+ /** The kinds `GET …/items` recognises (`src/lib/item-kind.ts`). */
6
+ const ITEM_KINDS = ["request", "task", "epic"];
7
+ import { resolveWorkspace } from "../client.js";
8
+ import { clip, codeOf, dim, json, out, table, positiveInt } from "../output.js";
9
+
10
+ /**
11
+ * `zumino find <query>` — one read across every kind of item.
12
+ *
13
+ * Scoped to a project when one is resolved, and to the whole workspace when not.
14
+ * It reads the spine shape, which deliberately publishes no `status`
15
+ * (`docs/decisions/0003`) — so the STATE column says open or closed, and a
16
+ * per-kind status needs the kind's own read.
17
+ */
18
+ export async function run(args, flags) {
19
+ const ctx = resolveContext(flags);
20
+ const workspace = await resolveWorkspace({ ...ctx, project: ctx.project });
21
+ const q = args.join(" ").trim();
22
+
23
+ const path = ctx.project
24
+ ? `/workspaces/${workspace}/projects/${ctx.project}/items`
25
+ : `/workspaces/${workspace}/items`;
26
+
27
+ // `--state`, not `--status`. The generic spine read deliberately publishes no
28
+ // per-kind status (docs/decisions/0003) and understands only `open` and
29
+ // `closed`; anything else was silently dropped server-side and answered 200
30
+ // with an unfiltered list, so a wrong filter looked like a real result.
31
+ if (flags.status) {
32
+ throw new CliError(
33
+ `find has no --status: the read that spans every kind publishes only open/closed.`,
34
+ {
35
+ hint:
36
+ `Did you mean --state ${/^(open|closed)$/.test(flags.status) ? flags.status : "open"}? ` +
37
+ `For a per-kind status, use that kind's own read, e.g. zumino task show <CODE>.`,
38
+ },
39
+ );
40
+ }
41
+ if (flags.state && !/^(open|closed)$/.test(flags.state)) {
42
+ throw new CliError(`--state takes open or closed, not "${flags.state}".`);
43
+ }
44
+ // Validated here for the same reason `--status` is, one flag over: the server
45
+ // keeps only recognised kinds and turns an empty set into `false`, so a wrong
46
+ // value answers 200 with an empty page. The skill prescribes `zumino find` as
47
+ // the duplicate check before filing, and `--kind bug` or `--kind feature` are
48
+ // plausible mistakes — they are real task *types* in this product — so a
49
+ // confident "Nothing matches" is how an agent files a duplicate.
50
+ if (flags.kind && !ITEM_KINDS.includes(flags.kind)) {
51
+ throw new CliError(`--kind takes ${ITEM_KINDS.join(", ")}, not "${flags.kind}".`, {
52
+ hint: "bug and feature are task *types*, not kinds — filter those with zumino task show.",
53
+ });
54
+ }
55
+ const res = await api(ctx, "GET", path, {
56
+ query: { q: q || undefined, kind: flags.kind, state: flags.state, limit: positiveInt(flags.limit, "limit") },
57
+ });
58
+
59
+ const items = res?.items ?? [];
60
+ if (flags.json) {
61
+ json(items);
62
+ return 0;
63
+ }
64
+ if (items.length === 0) {
65
+ out(q ? `Nothing matches "${q}".` : "No items.");
66
+ return 0;
67
+ }
68
+
69
+ table(
70
+ items.map((i) => [
71
+ codeOf(i) || `#${i.number}`,
72
+ i.kind ?? "",
73
+ i.state ?? "",
74
+ clip(i.title),
75
+ dim(i.project?.slug ?? ""),
76
+ ]),
77
+ { head: ["CODE", "KIND", "STATE", "TITLE", "PROJECT"] },
78
+ );
79
+ if (typeof res.total === "number" && res.total > items.length) {
80
+ out(dim(`\n${items.length} of ${res.total} — narrow with --kind, or raise --limit.`));
81
+ }
82
+ return 0;
83
+ }