@zumino/cli 2.1.0 → 2.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/src/spec.js ADDED
@@ -0,0 +1,1015 @@
1
+ /*
2
+ * ─────────────────────────────────────────────────────────────────────────────
3
+ * THE COMMAND SURFACE, DECLARED ONCE.
4
+ *
5
+ * Every command, every flag, every value a flag accepts. Five things read this
6
+ * file and nothing restates it:
7
+ *
8
+ * 1. `parseArgs` — its `options` map is derived from `FLAGS` (`parseOptions`).
9
+ * 2. validation — a flag not declared on the command is refused, and so is a
10
+ * value outside the declared set (`flags.js`).
11
+ * 3. `--help` and `zumino help <command>` — rendered from here (`help.js`).
12
+ * 4. `zumino commands [--json]` — the whole surface, for an agent to read.
13
+ * 5. `test/cli-vocabulary.test.ts` in the app — holds `VALUES` against the
14
+ * server's own `TASK_STATUSES`, `ENTRY_STATUSES`, `AUDIT_EVENT_KINDS` and
15
+ * the rest, so a vocabulary that grows in the product cannot quietly stop
16
+ * being offered here.
17
+ *
18
+ * **Why one table rather than help text beside each command.** The first
19
+ * version of this CLI documented its flags in a hand-written `USAGE` string and
20
+ * validated three of them. The result was the failure class this project
21
+ * already had a name for — a caller value the server silently drops — one flag
22
+ * at a time: `--status` on `find` went to the wire as a parameter the generic
23
+ * item read does not have, answered 200, and printed an unfiltered list as if
24
+ * it were a filtered one. Generated help cannot describe a flag the validator
25
+ * does not know about, and the validator cannot accept one the help does not
26
+ * list, because they are the same line of this file.
27
+ *
28
+ * **It imports nothing**, deliberately: the app's test suite imports it
29
+ * directly, and a `node:`-flavoured dependency here would drag the CLI's
30
+ * runtime into a vitest process that has no business loading it.
31
+ *
32
+ * ── The one rule about filters ──────────────────────────────────────────────
33
+ *
34
+ * **Every filter here is a filter the API performs.** Nothing in this CLI
35
+ * narrows a list locally. A client-side filter reads identically to a
36
+ * server-side one and is wrong in a way nobody can see: it filters the page it
37
+ * was handed, so `--limit 50` plus a local filter silently answers from the
38
+ * first fifty rows rather than from the project. Where the API has no filter,
39
+ * this file has no flag — see `epic list`, which is unpaginated and unfiltered
40
+ * on the server and therefore here too.
41
+ * ─────────────────────────────────────────────────────────────────────────────
42
+ */
43
+
44
+ /**
45
+ * The vocabularies the server owns, copied here so a value can be refused
46
+ * before it is sent rather than ignored after.
47
+ *
48
+ * A copy is a liability, so it is held in step mechanically:
49
+ * `test/cli-vocabulary.test.ts` fails when any list here disagrees with the
50
+ * constant it mirrors. Adding a status to the product is one line there and one
51
+ * line here, in the same pull request.
52
+ */
53
+ export const VALUES = {
54
+ /** `src/lib/item-kind.ts` */
55
+ itemKind: ["request", "task", "epic"],
56
+ /** Derived from `closedAt` by the generic spine read — not a status. */
57
+ itemState: ["open", "closed"],
58
+ itemSort: ["updated", "new", "code"],
59
+
60
+ /** `TASK_STATUSES` in `src/lib/task-meta.ts` */
61
+ taskStatus: [
62
+ "backlog",
63
+ "todo",
64
+ "shaping",
65
+ "in_progress",
66
+ "in_review",
67
+ "done",
68
+ "wont_do",
69
+ ],
70
+ /** `TASK_TYPES` */
71
+ taskType: ["feature", "bug", "improvement", "refactor", "investigation", "chore"],
72
+ /** `TASK_PRIORITIES` */
73
+ taskPriority: ["critical", "high", "normal", "low"],
74
+ /** `TASK_SIZES` */
75
+ taskSize: ["S", "M", "L", "XL"],
76
+ /** `TASK_CLARITIES` */
77
+ taskClarity: ["well_defined", "needs_investigation", "exploratory"],
78
+ taskSort: ["priority", "new", "updated", "manual"],
79
+
80
+ /** `ENTRY_STATUSES` in `src/lib/entry-meta.ts` */
81
+ requestStatus: ["open", "planned", "in_progress", "done", "wont_do"],
82
+ /** `ENTRY_TYPES` */
83
+ requestType: ["feature", "bug", "improvement", "idea"],
84
+ requestSort: ["top", "new"],
85
+
86
+ /** `EPIC_STATUSES` in `src/lib/epic-meta.ts` */
87
+ epicStatus: ["open", "in_progress", "done", "wont_do"],
88
+
89
+ /** `AUDIT_EVENT_KINDS` in `src/lib/audit-event-meta.ts` */
90
+ eventKind: [
91
+ "created",
92
+ "field",
93
+ "status",
94
+ "spec",
95
+ "attention",
96
+ "tag",
97
+ "link",
98
+ "ref",
99
+ "attachment",
100
+ "detached",
101
+ "pin",
102
+ "response",
103
+ "promoted",
104
+ ],
105
+
106
+ /** The link vocabulary as a person says it, `task link` maps it to the API's. */
107
+ linkType: ["blocks", "blocked-by", "related", "answers"],
108
+
109
+ boolean: ["true", "false"],
110
+ };
111
+
112
+ /**
113
+ * Every flag the CLI understands, with the one-line help it is documented by.
114
+ *
115
+ * `type` is `parseArgs`'s. `multiple` means the flag may be repeated — and that
116
+ * `parseArgs` hands back an array, which is why a command that takes one value
117
+ * reads it through `oneValue()` rather than trusting the shape.
118
+ *
119
+ * A command overrides `help` and adds `values` where the accepted set depends on
120
+ * which list is being read: `--kind` is an item kind on `find` and an event kind
121
+ * on `task events`, and `--status` has three different vocabularies.
122
+ */
123
+ export const FLAGS = {
124
+ // --- global ---------------------------------------------------------------
125
+ json: { type: "boolean", help: "print the API's own JSON instead of a table" },
126
+ host: { type: "string", arg: "URL", help: "the Zumino server to talk to" },
127
+ token: { type: "string", arg: "TOKEN", help: "a personal access token, used for this call only" },
128
+ account: { type: "string", arg: "NAME", help: "use a stored account by name" },
129
+ project: { type: "string", arg: "SLUG", help: "the project to act in" },
130
+ workspace: { type: "string", arg: "SLUG", help: "the workspace to act in" },
131
+ help: { type: "boolean", short: "h", help: "this page" },
132
+ version: { type: "boolean", help: "print the CLI version" },
133
+
134
+ // --- paging and search ----------------------------------------------------
135
+ limit: {
136
+ type: "string",
137
+ arg: "N",
138
+ numeric: "positive",
139
+ help: "how many rows to return (1–200, default 50)",
140
+ },
141
+ offset: {
142
+ type: "string",
143
+ arg: "N",
144
+ numeric: "nonNegative",
145
+ help: "skip N rows — page with it, not by raising --limit",
146
+ },
147
+ query: { type: "string", short: "q", arg: "TEXT", help: "match title and description" },
148
+ sort: { type: "string", arg: "ORDER", help: "the order rows come back in" },
149
+
150
+ // --- filters --------------------------------------------------------------
151
+ kind: { type: "string", arg: "KIND", multiple: true, help: "narrow by kind" },
152
+ state: { type: "string", arg: "STATE", help: "open or closed" },
153
+ status: { type: "string", arg: "STATUS", multiple: true, help: "narrow by status" },
154
+ type: { type: "string", arg: "TYPE", help: "narrow by type" },
155
+ priority: { type: "string", arg: "PRIORITY", help: "narrow by priority" },
156
+ assignee: { type: "string", arg: "USER", shape: "user", help: "a user id, or `me`" },
157
+ tag: { type: "string", arg: "TAG_ID", help: "a tag id — list them with: zumino project tags" },
158
+ epic: { type: "string", arg: "E<n>", help: "an epic in this project, or `none` for the ungrouped" },
159
+ "needs-input": { type: "boolean", help: "only what is waiting on a person" },
160
+ open: { type: "boolean", help: "hide closed rows" },
161
+ field: { type: "string", arg: "FIELD", help: "narrow to one field that moved, e.g. status" },
162
+ before: { type: "string", arg: "CURSOR", help: "the `nextBefore` from the previous page" },
163
+ activity: { type: "boolean", help: "fold the activity log into the brief" },
164
+
165
+ // --- item fields ----------------------------------------------------------
166
+ title: { type: "string", arg: "TEXT", help: "the title" },
167
+ description: { type: "string", arg: "TEXT", help: "markdown; empty clears it" },
168
+ size: { type: "string", arg: "SIZE", help: "rough size" },
169
+ clarity: { type: "string", arg: "CLARITY", help: "how well understood the work is" },
170
+ reviewer: {
171
+ type: "string",
172
+ arg: "USER",
173
+ shape: "user",
174
+ clearable: true,
175
+ help: "who reviews it — a user id, `me`, or `-` to clear",
176
+ },
177
+ pinned: { type: "string", arg: "BOOL", help: "pin the request to the top of the public board" },
178
+ plan: { type: "string", arg: "TEXT", help: "the plan — how this gets done" },
179
+ acceptance: { type: "string", arg: "TEXT", help: "the acceptance criteria — when it is done" },
180
+ url: { type: "string", arg: "URL", help: "the pull request, commit or issue" },
181
+ body: { type: "string", arg: "TEXT", help: "the text, when it is easier to pass as a flag" },
182
+ to: { type: "string", arg: "SLUG", help: "the work project the task is filed in" },
183
+
184
+ // --- housekeeping ---------------------------------------------------------
185
+ dir: { type: "string", arg: "PATH", help: "where to install the skill" },
186
+ check: { type: "boolean", help: "report whether the installed skill is current; write nothing" },
187
+ yes: { type: "boolean", short: "y", help: "do not ask" },
188
+ };
189
+
190
+ /**
191
+ * The flags every command takes, so no command declares them.
192
+ *
193
+ * They are how a call is addressed rather than what it does — which credential,
194
+ * which host, which project, and whether the answer is for a person or for
195
+ * `jq`. `zumino auth status` says which of them won.
196
+ */
197
+ export const GLOBAL_FLAGS = [
198
+ "json",
199
+ "host",
200
+ "token",
201
+ "account",
202
+ "project",
203
+ "workspace",
204
+ "help",
205
+ ];
206
+
207
+ /*
208
+ * ── The commands ────────────────────────────────────────────────────────────
209
+ *
210
+ * Shape is `DOMAIN.md`'s rule and not a style choice: **generic reads span
211
+ * every kind and are top-level; every write names its kind.** So `queue`,
212
+ * `context` and `find` are verbs at the root, and everything that touches one
213
+ * kind hangs off that kind's noun — which is also why a fourth kind would add a
214
+ * noun rather than widen a flat verb list every script has already learned.
215
+ *
216
+ * A noun's own list (`task list`, `request list`) sits under the noun for the
217
+ * same reason: it reads that kind's detail endpoint, with that kind's
218
+ * vocabulary, and publishes fields `find` deliberately does not have.
219
+ */
220
+
221
+ /** @typedef {{name: string, summary: string, usage?: string, args?: Array<[string,string]>, flags?: Record<string, object>, rejects?: Record<string, {message: string, hint?: string}>, examples?: string[], notes?: string, group?: string, subcommands?: any[]}} Command */
222
+
223
+ /** @type {Command[]} */
224
+ export const COMMANDS = [
225
+ // --- orientation ----------------------------------------------------------
226
+ {
227
+ name: "workspace",
228
+ group: "orient",
229
+ summary: "the workspaces you belong to",
230
+ subcommands: [
231
+ {
232
+ name: "list",
233
+ summary: "every workspace the token's owner belongs to, and their role in each",
234
+ usage: "zumino workspace list",
235
+ notes:
236
+ "The bootstrap read. Every other path carries a workspace segment, so " +
237
+ "a fresh token cannot build a single URL until this one has answered. " +
238
+ "`role` comes with it because it is what predicts whether a " +
239
+ "moderator-only write will be refused.",
240
+ examples: [
241
+ "zumino workspace list",
242
+ "zumino workspace list --json | jq -r '.[].slug'",
243
+ ],
244
+ },
245
+ ],
246
+ },
247
+ {
248
+ name: "project",
249
+ group: "orient",
250
+ summary: "the projects you can reach, and what is in one",
251
+ subcommands: [
252
+ {
253
+ name: "list",
254
+ summary: "every project you can reach, with its workspace, type and size",
255
+ usage: "zumino project list [--workspace SLUG]",
256
+ notes:
257
+ "One of the two genuinely cross-workspace reads, so it is narrowed by " +
258
+ "`--workspace` rather than addressed under one. A feedback project has " +
259
+ "no key and holds requests; a work project has a key and holds tasks " +
260
+ "and epics — which is what decides whether `zumino task …` or " +
261
+ "`zumino request …` is the right half of the CLI.",
262
+ examples: [
263
+ "zumino project list",
264
+ "zumino project list --workspace acme",
265
+ "zumino project list --json | jq -r '.[] | select(.type==\"work\") | .slug'",
266
+ ],
267
+ },
268
+ {
269
+ name: "show",
270
+ summary: "one project, its type, its key and its counters",
271
+ usage: "zumino project show [<slug>]",
272
+ args: [["<slug>", "the project; defaults to the resolved --project"]],
273
+ examples: ["zumino project show acme-app", "zumino project show"],
274
+ },
275
+ {
276
+ name: "tags",
277
+ summary: "the project's tag palette — the ids `--tag` takes",
278
+ usage: "zumino project tags [<slug>]",
279
+ args: [["<slug>", "the project; defaults to the resolved --project"]],
280
+ notes:
281
+ "A tag is filtered by id, not by name, so this is the read that makes " +
282
+ "`--tag` usable at all.",
283
+ examples: [
284
+ "zumino project tags",
285
+ "zumino project tags --json | jq -r '.[] | select(.name==\"infra\") | .id'",
286
+ ],
287
+ },
288
+ ],
289
+ },
290
+
291
+ // --- reads across every kind ---------------------------------------------
292
+ {
293
+ name: "queue",
294
+ group: "read",
295
+ summary: "what to work on next, across every project you can reach",
296
+ usage: "zumino queue [--needs-input] [--limit N]",
297
+ flags: {
298
+ "needs-input": { help: "tasks waiting on you, instead of tasks you can pick up" },
299
+ limit: true,
300
+ },
301
+ notes:
302
+ "Availability is computed rather than read off a column: committed, " +
303
+ "shaped, and unblocked. So an empty answer is a finding — the specs are " +
304
+ "below the bar or their blockers are open — and this command says which " +
305
+ "rather than printing nothing.",
306
+ examples: [
307
+ "zumino queue",
308
+ "zumino queue --needs-input",
309
+ "zumino queue --workspace acme --limit 5",
310
+ ],
311
+ },
312
+ {
313
+ name: "context",
314
+ group: "read",
315
+ summary: "the whole brief for one task, as one markdown document",
316
+ usage: "zumino context <CODE> [--activity]",
317
+ args: [["<CODE>", "a task — `ACME-14`, or `14` with a project resolved"]],
318
+ flags: { activity: true },
319
+ notes:
320
+ "The epic, the description, the plan, the acceptance criteria, the " +
321
+ "blockers and the requests it answers, assembled by the server. Prefer it " +
322
+ "over six reads not to save round trips but because an agent that " +
323
+ "assembles its own brief assembles a plausible one rather than the real " +
324
+ "one. Readiness goes to stderr, so the document alone is what a pipe sees.",
325
+ examples: ["zumino context ACME-14", "zumino context ACME-14 --activity"],
326
+ },
327
+ {
328
+ name: "find",
329
+ group: "read",
330
+ summary: "one search across every kind of item",
331
+ usage: "zumino find [<text>] [filters]",
332
+ args: [["<text>", "matched against title and description; same as --query"]],
333
+ flags: {
334
+ kind: { values: "itemKind", help: "request, task or epic — repeatable, or comma-separated" },
335
+ state: { values: "itemState" },
336
+ assignee: true,
337
+ query: true,
338
+ sort: { values: "itemSort", help: "default: updated" },
339
+ offset: true,
340
+ limit: true,
341
+ },
342
+ rejects: {
343
+ status: {
344
+ message:
345
+ "find has no --status: the read that spans every kind publishes only open/closed.",
346
+ hint:
347
+ "Did you mean --state open? For a per-kind status, use that kind's own " +
348
+ "list: zumino task list --status in_progress.",
349
+ },
350
+ priority: {
351
+ message: "find has no --priority: priority belongs to tasks, not to every kind.",
352
+ hint: "Use: zumino task list --priority high",
353
+ },
354
+ },
355
+ notes:
356
+ "Scoped to a project when one is resolved, and to the whole workspace " +
357
+ "when not. It reads the spine shape, which deliberately publishes no " +
358
+ "per-kind status (docs/decisions/0003) — hence STATE rather than STATUS, " +
359
+ "and hence `task list` for anything a task alone has.",
360
+ examples: [
361
+ "zumino find \"rate limit\"",
362
+ "zumino find --kind task,epic --state open",
363
+ "zumino find --assignee me --sort new --limit 10",
364
+ "zumino find --json | jq -r '.[].code'",
365
+ ],
366
+ },
367
+
368
+ // --- work -----------------------------------------------------------------
369
+ {
370
+ name: "task",
371
+ group: "work",
372
+ summary: "the work layer of a work project — read it, file it, move it",
373
+ subcommands: [
374
+ {
375
+ name: "list",
376
+ summary: "the project's tasks, with every filter the board itself has",
377
+ usage: "zumino task list [<text>] [filters]",
378
+ args: [["<text>", "matched against title and description; same as --query"]],
379
+ flags: {
380
+ status: {
381
+ values: "taskStatus",
382
+ help: "repeatable, or comma-separated — e.g. --status todo,in_progress",
383
+ },
384
+ type: { values: "taskType" },
385
+ priority: { values: "taskPriority" },
386
+ assignee: true,
387
+ epic: true,
388
+ tag: true,
389
+ "needs-input": { help: "only tasks flagged as waiting on a person" },
390
+ open: true,
391
+ query: true,
392
+ sort: { values: "taskSort", help: "default: priority" },
393
+ offset: true,
394
+ limit: true,
395
+ },
396
+ notes:
397
+ "`--sort manual` is the stored order — what the board draws and what " +
398
+ "somebody dragged it into. It is the read half of hand-ordering, which " +
399
+ "was a write with no read until it was published here.",
400
+ examples: [
401
+ "zumino task list",
402
+ "zumino task list --status todo,in_progress --priority high",
403
+ "zumino task list --assignee me --open",
404
+ "zumino task list --epic E3 --sort manual",
405
+ "zumino task list --tag $(zumino project tags --json | jq -r '.[0].id')",
406
+ "zumino task list --status done --limit 100 --offset 100",
407
+ ],
408
+ },
409
+ {
410
+ name: "show",
411
+ summary: "one task: status, priority, assignee and description",
412
+ usage: "zumino task show <CODE>",
413
+ args: [["<CODE>", "`ACME-14`, or `14` with a project resolved"]],
414
+ examples: ["zumino task show ACME-14", "zumino task show 14 --json"],
415
+ },
416
+ {
417
+ name: "events",
418
+ summary: "what has been done to this task, newest first",
419
+ usage: "zumino task events <CODE> [--kind KIND] [--field FIELD]",
420
+ args: [["<CODE>", "the task"]],
421
+ flags: {
422
+ kind: { values: "eventKind", help: "one kind of change" },
423
+ field: { help: "one field that moved, e.g. status, priority, assigneeId" },
424
+ limit: true,
425
+ before: true,
426
+ },
427
+ notes:
428
+ "The counterpart to the comment thread: that records what people said, " +
429
+ "this records what anyone — person or agent — actually did. Read it " +
430
+ "before picking a task up: \"what was tried and then reverted\" is the " +
431
+ "context an executor otherwise has no way to recover, and re-doing an " +
432
+ "abandoned approach is the usual cost of not having it.",
433
+ examples: [
434
+ "zumino task events ACME-14",
435
+ "zumino task events ACME-14 --field status",
436
+ "zumino task events ACME-14 --kind spec --json",
437
+ ],
438
+ },
439
+ {
440
+ name: "create",
441
+ summary: "file a task",
442
+ usage: "zumino task create --title T [fields]",
443
+ args: [["<text>", "the title, if --title is not given"]],
444
+ flags: {
445
+ title: true,
446
+ description: {
447
+ help: "what this is, a couple of paragraphs — half the bar for the queue",
448
+ },
449
+ type: { values: "taskType" },
450
+ priority: { values: "taskPriority" },
451
+ status: { values: "taskStatus", help: "defaults to backlog" },
452
+ size: { values: "taskSize" },
453
+ clarity: { values: "taskClarity" },
454
+ assignee: { clearable: true, help: "a user id, or `me`" },
455
+ epic: { help: "an epic in this project — E3 or 3" },
456
+ },
457
+ notes:
458
+ "A title is the only requirement: capture has to be cheap or it does " +
459
+ "not happen. A description plus acceptance criteria is what puts the " +
460
+ "task in `zumino queue` — write them with `zumino task spec`.",
461
+ examples: [
462
+ "zumino task create --title \"Retry the webhook\"",
463
+ "zumino task create --title \"Fix the 429\" --type bug --priority high --epic E3",
464
+ ],
465
+ },
466
+ {
467
+ name: "edit",
468
+ summary: "change any field on a task, in one call",
469
+ usage: "zumino task edit <CODE> [fields]",
470
+ args: [["<CODE>", "the task"]],
471
+ flags: {
472
+ title: true,
473
+ description: true,
474
+ type: { values: "taskType" },
475
+ status: { values: "taskStatus" },
476
+ priority: { values: "taskPriority" },
477
+ size: { values: "taskSize", clearable: true, help: "rough size, or `-` to clear" },
478
+ clarity: { values: "taskClarity", clearable: true, help: "or `-` to clear" },
479
+ assignee: { clearable: true, help: "a user id, `me`, or `-` to clear" },
480
+ reviewer: true,
481
+ epic: { help: "E3 or 3, or `none` to detach" },
482
+ },
483
+ notes:
484
+ "One PATCH, so three fields are one audit group and render as one " +
485
+ "sentence in the history. Only what is named is sent: a flag left out " +
486
+ "is untouched, and an empty value clears a field that is allowed to be " +
487
+ "empty. `zumino task status` and `zumino task assign` stay because they " +
488
+ "are the two an agent does constantly and they want to be three words.",
489
+ examples: [
490
+ "zumino task edit ACME-14 --priority critical --status in_progress",
491
+ "zumino task edit ACME-14 --size L --clarity needs_investigation",
492
+ "zumino task edit ACME-14 --epic none",
493
+ ],
494
+ },
495
+ {
496
+ name: "status",
497
+ summary: "move a task",
498
+ usage: "zumino task status <CODE> <status>",
499
+ args: [["<CODE>", "the task"], ["<status>", VALUES.taskStatus.join(" · ")]],
500
+ examples: ["zumino task status ACME-14 in_progress", "zumino task status ACME-14 done"],
501
+ },
502
+ {
503
+ name: "assign",
504
+ summary: "set or clear the assignee",
505
+ usage: "zumino task assign <CODE> <userId|me|->",
506
+ args: [
507
+ ["<CODE>", "the task"],
508
+ ["<userId|me|->", "a user id, `me`, or `-` to clear"],
509
+ ],
510
+ notes:
511
+ "An assignee is always a person, even when an agent does the work. " +
512
+ "There is no delegation object and no executor registry: an agent reads " +
513
+ "the queue, pulls the brief, and writes back through the endpoints a " +
514
+ "person uses.",
515
+ examples: ["zumino task assign ACME-14 me", "zumino task assign ACME-14 -"],
516
+ },
517
+ {
518
+ name: "spec",
519
+ summary: "write the plan, or the acceptance criteria",
520
+ usage: "zumino task spec <CODE> --plan TEXT | --acceptance TEXT",
521
+ args: [["<CODE>", "the task"]],
522
+ flags: { plan: true, acceptance: true },
523
+ notes:
524
+ "One section per call, on purpose: an agent redrafting the plan must " +
525
+ "not be able to touch the criteria it will be judged against. An empty " +
526
+ "value clears the section.",
527
+ examples: [
528
+ "zumino task spec ACME-14 --plan \"$(cat plan.md)\"",
529
+ "zumino task spec ACME-14 --acceptance \"429 answers carry Retry-After\"",
530
+ ],
531
+ },
532
+ {
533
+ name: "comment",
534
+ summary: "say something on a task",
535
+ usage: "zumino task comment <CODE> <text>",
536
+ args: [["<CODE>", "the task"], ["<text>", "the comment"]],
537
+ flags: { body: true },
538
+ examples: ["zumino task comment ACME-14 \"Blocked on the migration\""],
539
+ },
540
+ {
541
+ name: "link",
542
+ summary: "relate two items",
543
+ usage: "zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE|project#N>",
544
+ args: [
545
+ ["<CODE>", "the task the link is described from"],
546
+ ["<type>", VALUES.linkType.join(" · ")],
547
+ ["<CODE|project#N>", "the far end — `answers` takes a request, written project#N"],
548
+ ],
549
+ notes:
550
+ "`blocks` and `blocked-by` are the same stored row read from either " +
551
+ "end. `answers` says this work exists because of a request — the " +
552
+ "request is not consumed and does not change kind (docs/decisions/0002).",
553
+ examples: [
554
+ "zumino task link ACME-14 blocked-by ACME-9",
555
+ "zumino task link ACME-14 answers acme-board#42",
556
+ ],
557
+ },
558
+ {
559
+ name: "ref",
560
+ summary: "attach the pull request that answers the task",
561
+ usage: "zumino task ref <CODE> --url URL [--title T]",
562
+ args: [["<CODE>", "the task"]],
563
+ flags: { url: true, title: { help: "what to call it" } },
564
+ examples: ["zumino task ref ACME-14 --url https://github.com/acme/app/pull/99"],
565
+ },
566
+ {
567
+ name: "attention",
568
+ summary: "flag a task as waiting on a person, or clear the flag",
569
+ usage: "zumino task attention <CODE> [reason|-]",
570
+ args: [["<CODE>", "the task"], ["[reason|-]", "why it is stuck; `-` or nothing clears it"]],
571
+ notes: "What `zumino queue --needs-input` answers with.",
572
+ examples: [
573
+ "zumino task attention ACME-14 \"Which bucket should this charge?\"",
574
+ "zumino task attention ACME-14 -",
575
+ ],
576
+ },
577
+ {
578
+ name: "tag",
579
+ summary: "put a tag on a task",
580
+ usage: "zumino task tag <CODE> <tagId>",
581
+ args: [["<CODE>", "the task"], ["<tagId>", "from: zumino project tags"]],
582
+ examples: ["zumino task tag ACME-14 tag_abc123"],
583
+ },
584
+ {
585
+ name: "untag",
586
+ summary: "take a tag off a task",
587
+ usage: "zumino task untag <CODE> <tagId>",
588
+ args: [["<CODE>", "the task"], ["<tagId>", "from: zumino project tags"]],
589
+ examples: ["zumino task untag ACME-14 tag_abc123"],
590
+ },
591
+ ],
592
+ },
593
+ {
594
+ name: "request",
595
+ group: "work",
596
+ summary: "what people asked for, on a feedback project",
597
+ subcommands: [
598
+ {
599
+ name: "list",
600
+ summary: "the project's requests, filtered",
601
+ usage: "zumino request list [<text>] [filters]",
602
+ args: [["<text>", "matched against title and description; same as --query"]],
603
+ flags: {
604
+ status: {
605
+ values: "requestStatus",
606
+ help: "repeatable, or comma-separated — e.g. --status open,planned",
607
+ },
608
+ type: { values: "requestType" },
609
+ tag: true,
610
+ query: true,
611
+ sort: { values: "requestSort", help: "default: top, which is most voted" },
612
+ offset: true,
613
+ limit: true,
614
+ },
615
+ examples: [
616
+ "zumino request list",
617
+ "zumino request list --status open --sort top --limit 10",
618
+ "zumino request list \"dark mode\" --json",
619
+ ],
620
+ },
621
+ {
622
+ name: "show",
623
+ summary: "one request: status, votes and the words as written",
624
+ usage: "zumino request show <#N|CODE>",
625
+ args: [["<#N|CODE>", "`#42` on a feedback project, which has no key"]],
626
+ examples: ["zumino request show 42"],
627
+ },
628
+ {
629
+ name: "events",
630
+ summary: "the request's history — moderator-only",
631
+ usage: "zumino request events <#N|CODE> [--kind KIND] [--field FIELD]",
632
+ args: [["<#N|CODE>", "the request"]],
633
+ flags: {
634
+ kind: { values: "eventKind", help: "one kind of change" },
635
+ field: true,
636
+ limit: true,
637
+ before: true,
638
+ },
639
+ notes:
640
+ "Answers 404 rather than 403 to anyone who does not moderate the " +
641
+ "project: the history carries actor names, both sides of the internal " +
642
+ "note, and every decision that was reverted.",
643
+ examples: ["zumino request events 42", "zumino request events 42 --kind response"],
644
+ },
645
+ {
646
+ name: "create",
647
+ summary: "file a request",
648
+ usage: "zumino request create --title T [--description D] [--type TYPE]",
649
+ args: [["<text>", "the title, if --title is not given"]],
650
+ flags: { title: true, description: true, type: { values: "requestType" } },
651
+ examples: ["zumino request create --title \"Dark mode\" --type idea"],
652
+ },
653
+ {
654
+ name: "edit",
655
+ summary: "change a request's words, type, status or pinning",
656
+ usage: "zumino request edit <#N|CODE> [fields]",
657
+ args: [["<#N|CODE>", "the request"]],
658
+ flags: {
659
+ title: true,
660
+ description: true,
661
+ type: { values: "requestType" },
662
+ status: { values: "requestStatus" },
663
+ pinned: { values: "boolean" },
664
+ },
665
+ notes:
666
+ "The words are the author's or a moderator's; the status and the " +
667
+ "pinning are a moderator's. Only what is named is sent.",
668
+ examples: [
669
+ "zumino request edit 42 --type bug",
670
+ "zumino request edit 42 --pinned true",
671
+ ],
672
+ },
673
+ {
674
+ name: "answer",
675
+ summary: "post a comment and mark it the official answer",
676
+ usage: "zumino request answer <#N|CODE> <text>",
677
+ args: [["<#N|CODE>", "the request"], ["<text>", "the answer"]],
678
+ flags: { body: true },
679
+ notes:
680
+ "Two calls, because the API models it as two things: at most one " +
681
+ "comment on a request may be official, so the answer is a comment that " +
682
+ "has been designated rather than a field. If the second call is " +
683
+ "refused, the failure says so and names the comment that was already " +
684
+ "posted — never re-run it, or a second comment lands on a public board.",
685
+ examples: ["zumino request answer 42 \"Shipping in 2.3 — tracked as ACME-51.\""],
686
+ },
687
+ {
688
+ name: "status",
689
+ summary: "decide a request",
690
+ usage: "zumino request status <#N|CODE> <status>",
691
+ args: [["<#N|CODE>", "the request"], ["<status>", VALUES.requestStatus.join(" · ")]],
692
+ notes: "Owner or admin only. `done` and `wont_do` close the item.",
693
+ examples: ["zumino request status 42 planned"],
694
+ },
695
+ {
696
+ name: "note",
697
+ summary: "write the team's internal note, which the public never sees",
698
+ usage: "zumino request note <#N|CODE> <text|->",
699
+ args: [["<#N|CODE>", "the request"], ["<text|->", "the note; `-` clears it"]],
700
+ examples: ["zumino request note 42 \"Third report this month.\""],
701
+ },
702
+ {
703
+ name: "comment",
704
+ summary: "comment on a request, without marking it the answer",
705
+ usage: "zumino request comment <#N|CODE> <text>",
706
+ args: [["<#N|CODE>", "the request"], ["<text>", "the comment"]],
707
+ flags: { body: true },
708
+ examples: ["zumino request comment 42 \"Which browser?\""],
709
+ },
710
+ {
711
+ name: "promote",
712
+ summary: "file work that answers a request",
713
+ usage: "zumino request promote <#N|CODE> --to <work-project>",
714
+ args: [["<#N|CODE>", "the request"]],
715
+ flags: { to: true },
716
+ notes:
717
+ "A request never becomes a task (docs/decisions/0002). This creates a " +
718
+ "task that answers it, and both go on living — which is why the " +
719
+ "destination is `--to` and not `--project`: `--project` names where the " +
720
+ "request lives.",
721
+ examples: ["zumino request promote 42 --to acme-app"],
722
+ },
723
+ {
724
+ name: "tag",
725
+ summary: "put a tag on a request",
726
+ usage: "zumino request tag <#N|CODE> <tagId>",
727
+ args: [["<#N|CODE>", "the request"], ["<tagId>", "from: zumino project tags"]],
728
+ examples: ["zumino request tag 42 tag_abc123"],
729
+ },
730
+ {
731
+ name: "untag",
732
+ summary: "take a tag off a request",
733
+ usage: "zumino request untag <#N|CODE> <tagId>",
734
+ args: [["<#N|CODE>", "the request"], ["<tagId>", "from: zumino project tags"]],
735
+ examples: ["zumino request untag 42 tag_abc123"],
736
+ },
737
+ ],
738
+ },
739
+ {
740
+ name: "epic",
741
+ group: "work",
742
+ summary: "the outcomes a project is organised around",
743
+ subcommands: [
744
+ {
745
+ name: "list",
746
+ summary: "every epic in the project, with progress",
747
+ usage: "zumino epic list",
748
+ notes:
749
+ "Unfiltered and unpaginated, because the endpoint is: epics are the " +
750
+ "handful of outcomes a project is organised around, not its inventory. " +
751
+ "A project with enough of them to need a page has stopped using them as " +
752
+ "groupings. Filter the tasks under one instead: `zumino task list " +
753
+ "--epic E3`.",
754
+ examples: ["zumino epic list", "zumino epic list --json"],
755
+ },
756
+ {
757
+ name: "show",
758
+ summary: "one epic and how far through it is",
759
+ usage: "zumino epic show <E<n>|CODE>",
760
+ args: [["<E<n>|CODE>", "`E3`, or `ACME-E3`"]],
761
+ examples: ["zumino epic show E3"],
762
+ },
763
+ {
764
+ name: "create",
765
+ summary: "name an outcome",
766
+ usage: "zumino epic create --title T [--description D]",
767
+ args: [["<text>", "the title, if --title is not given"]],
768
+ flags: {
769
+ title: true,
770
+ description: {
771
+ help: "the brief for the whole outcome — handed to whoever works any task under it",
772
+ },
773
+ status: { values: "epicStatus", help: "defaults to open" },
774
+ assignee: { clearable: true, help: "who owns the outcome — a user id or `me`" },
775
+ },
776
+ examples: ["zumino epic create --title \"Make the API boring\""],
777
+ },
778
+ {
779
+ name: "edit",
780
+ summary: "change an epic's title, brief, status or owner",
781
+ usage: "zumino epic edit <E<n>|CODE> [fields]",
782
+ args: [["<E<n>|CODE>", "the epic"]],
783
+ flags: {
784
+ title: true,
785
+ description: true,
786
+ status: { values: "epicStatus" },
787
+ assignee: { clearable: true, help: "a user id, `me`, or `-` to clear" },
788
+ },
789
+ notes:
790
+ "There is no delete: retire an epic with `wont_do`, which keeps the " +
791
+ "record of what was decided against.",
792
+ examples: ["zumino epic edit E3 --status in_progress"],
793
+ },
794
+ {
795
+ name: "status",
796
+ summary: "move an epic",
797
+ usage: "zumino epic status <E<n>|CODE> <status>",
798
+ args: [["<E<n>|CODE>", "the epic"], ["<status>", VALUES.epicStatus.join(" · ")]],
799
+ examples: ["zumino epic status E3 done"],
800
+ },
801
+ ],
802
+ },
803
+
804
+ // --- everything else ------------------------------------------------------
805
+ {
806
+ name: "api",
807
+ group: "meta",
808
+ summary: "call any endpoint, including ones newer than this CLI",
809
+ usage: "zumino api <METHOD> <PATH> [--body JSON]",
810
+ args: [
811
+ ["<METHOD>", "GET · POST · PATCH · PUT · DELETE · HEAD"],
812
+ ["<PATH>", "below /api/v1, e.g. /queue or /workspaces/acme/items/ACME-14"],
813
+ ],
814
+ flags: { body: { help: "the request body, as JSON" } },
815
+ notes:
816
+ "Not a fallback for a missing command; it is what keeps the verb layer " +
817
+ "small enough to be worth learning. It is also why the server's `CLI_MIN` " +
818
+ "moves only for a breaking change: an old CLI reaches a new endpoint the " +
819
+ "day it ships. `zumino api GET /openapi.json` enumerates everything.",
820
+ examples: [
821
+ "zumino api GET /openapi.json",
822
+ "zumino api POST /workspaces/acme/projects/acme-app/tasks/14/vote",
823
+ "zumino api PUT /workspaces/acme/projects/acme-app/tasks/14/position --body '{\"after\":9}'",
824
+ ],
825
+ },
826
+ {
827
+ name: "auth",
828
+ group: "meta",
829
+ summary: "tokens, accounts, and which one is being used",
830
+ subcommands: [
831
+ {
832
+ name: "login",
833
+ summary: "store a token under an account name",
834
+ usage: "zumino auth login [--host URL] [--token TOKEN] [--account NAME]",
835
+ flags: { token: true, account: { help: "what to call this account locally" }, host: true },
836
+ notes:
837
+ "Nothing becomes \"current\": there is no stored active account and no " +
838
+ "`switch`, so two agents in two checkouts cannot change each other's " +
839
+ "identity (docs/decisions/0006). Tie a repository to an account with " +
840
+ "`zumino init`.",
841
+ examples: [
842
+ "zumino auth login --host https://zumino.cc",
843
+ "echo $TOKEN | zumino auth login --host https://zumino.cc --account ci",
844
+ ],
845
+ },
846
+ {
847
+ name: "status",
848
+ summary: "who you are acting as, on which host, and why that credential won",
849
+ usage: "zumino auth status",
850
+ notes:
851
+ "The first call to make in a session, and the answer to \"whoami\". It " +
852
+ "reports the resolved host and project, whether the token is " +
853
+ "read-only — checked here rather than discovered through a 403 halfway " +
854
+ "through a run of writes — and exits 3 if this CLI is too old for the " +
855
+ "server.",
856
+ examples: ["zumino auth status", "zumino auth status --json"],
857
+ },
858
+ {
859
+ name: "list",
860
+ summary: "the accounts stored on this machine",
861
+ usage: "zumino auth list",
862
+ examples: ["zumino auth list"],
863
+ },
864
+ {
865
+ name: "logout",
866
+ summary: "forget a stored account",
867
+ usage: "zumino auth logout [--account NAME] [-y]",
868
+ flags: { account: true, yes: true },
869
+ examples: ["zumino auth logout --account ci"],
870
+ },
871
+ ],
872
+ },
873
+ {
874
+ name: "init",
875
+ group: "meta",
876
+ summary: "tie this repository to an account, a host and a project",
877
+ usage: "zumino init [-y]",
878
+ flags: { yes: true },
879
+ notes:
880
+ "Writes `.zumino.json`, which is the first thing the resolution chain " +
881
+ "reads. It names a host, a workspace and a project — never a credential " +
882
+ "and never an account, because a file in a repository must not be able to " +
883
+ "choose which identity a command runs as.",
884
+ examples: ["zumino init"],
885
+ },
886
+ {
887
+ name: "skill",
888
+ group: "meta",
889
+ summary: "the agent skill this package bundles",
890
+ // The only group with a default: `zumino skill` has meant `zumino skill
891
+ // install` since the first release, and there is nothing else to do with a
892
+ // bundled document.
893
+ defaultSub: "install",
894
+ subcommands: [
895
+ {
896
+ name: "install",
897
+ summary: "install the skill globally, outside any one checkout",
898
+ usage: "zumino skill install [--dir PATH] [--check]",
899
+ flags: { dir: true, check: true },
900
+ notes:
901
+ "Global rather than per-repository: one copy that every session picks " +
902
+ "up, instead of one per checkout to forget to update. `--check` writes " +
903
+ "nothing and exits 1 when the installed copy is behind, which is what a " +
904
+ "hook branches on.",
905
+ examples: ["zumino skill install", "zumino skill install --check"],
906
+ },
907
+ ],
908
+ },
909
+ {
910
+ name: "self-update",
911
+ group: "meta",
912
+ summary: "update this CLI to the newest published version",
913
+ usage: "zumino self-update",
914
+ notes:
915
+ "Every API response carries the minimum and the newest CLI version, so " +
916
+ "staleness is detected on a call that was being made anyway. Below the " +
917
+ "minimum the CLI refuses to run and exits 3; merely behind, it says so on " +
918
+ "stderr once a day per host.",
919
+ examples: ["zumino self-update"],
920
+ },
921
+ {
922
+ name: "commands",
923
+ group: "meta",
924
+ summary: "every command, in one flat list — or the whole surface as JSON",
925
+ usage: "zumino commands [--json]",
926
+ flags: { json: { help: "the full surface: flags, accepted values, examples" } },
927
+ notes:
928
+ "What to read first when driving this CLI from a program. `--json` is the " +
929
+ "same declaration the help pages and the argument validator are generated " +
930
+ "from, so it cannot describe a command that does not exist or miss one " +
931
+ "that does.",
932
+ examples: [
933
+ "zumino commands",
934
+ "zumino commands --json | jq -r '.commands[].name'",
935
+ "zumino commands --json | jq '.values.taskStatus'",
936
+ ],
937
+ },
938
+ {
939
+ name: "help",
940
+ group: "meta",
941
+ summary: "the page for one command — flags, accepted values, examples",
942
+ usage: "zumino help [<command> [<subcommand>]]",
943
+ args: [
944
+ ["<command>", "e.g. task"],
945
+ ["<subcommand>", "e.g. list"],
946
+ ],
947
+ notes: "`zumino <command> --help` is the same page.",
948
+ examples: ["zumino help", "zumino help task", "zumino help task list"],
949
+ },
950
+ ];
951
+
952
+ /** Groups, in the order `--help` prints them. */
953
+ export const GROUPS = [
954
+ ["orient", "Orient"],
955
+ ["read", "Read across every kind"],
956
+ ["work", "Read and change one kind"],
957
+ ["meta", "Everything else"],
958
+ ];
959
+
960
+ /** Every top-level command, by name. */
961
+ export function command(name) {
962
+ return COMMANDS.find((c) => c.name === name);
963
+ }
964
+
965
+ /** One subcommand of a group, by name. */
966
+ export function subcommand(group, name) {
967
+ return group?.subcommands?.find((s) => s.name === name);
968
+ }
969
+
970
+ /** Every leaf command — a top-level verb, or a group's subcommand. */
971
+ export function leaves() {
972
+ return COMMANDS.flatMap((c) =>
973
+ c.subcommands
974
+ ? c.subcommands.map((s) => ({ ...s, path: `${c.name} ${s.name}`, parent: c }))
975
+ : [{ ...c, path: c.name, parent: null }],
976
+ );
977
+ }
978
+
979
+ /** The flags a leaf accepts, global ones included, as `{name: merged}`. */
980
+ export function flagsFor(leaf) {
981
+ /** @type {Record<string, any>} */
982
+ const all = {};
983
+ for (const name of GLOBAL_FLAGS) all[name] = { ...FLAGS[name], global: true };
984
+ for (const [name, override] of Object.entries(leaf?.flags ?? {})) {
985
+ const base = FLAGS[name];
986
+ all[name] = { ...base, ...(override === true ? {} : override), global: false };
987
+ }
988
+ return all;
989
+ }
990
+
991
+ /** The accepted values of a merged flag entry, or null when it takes free text. */
992
+ export function valuesOf(flag) {
993
+ return flag?.values ? (VALUES[flag.values] ?? null) : null;
994
+ }
995
+
996
+ /**
997
+ * `parseArgs`'s `options` map, derived from `FLAGS`.
998
+ *
999
+ * Lexing is global on purpose — `parseArgs` runs before the command is known —
1000
+ * so this is every flag the CLI has. Which of them the command in hand actually
1001
+ * accepts is the next question, and `flags.js` answers it against the spec
1002
+ * rather than leaving an unknown flag to be parsed and then ignored.
1003
+ */
1004
+ export function parseOptions() {
1005
+ /** @type {Record<string, {type: string, short?: string, multiple?: boolean}>} */
1006
+ const options = {};
1007
+ for (const [name, f] of Object.entries(FLAGS)) {
1008
+ options[name] = {
1009
+ type: f.type,
1010
+ ...(f.short ? { short: f.short } : {}),
1011
+ ...(f.multiple ? { multiple: true } : {}),
1012
+ };
1013
+ }
1014
+ return options;
1015
+ }