@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/README.md CHANGED
@@ -21,34 +21,73 @@ stale client is told on the call it was already making, and an incompatible one
21
21
  refuses to run rather than returning a plausible answer built on shapes the
22
22
  server no longer sends.
23
23
 
24
- ## Commands
24
+ ## Finding your way
25
+
26
+ The command list is not written down twice. `cli/src/spec.js` declares every
27
+ command, every flag and every value a flag accepts; the help pages, the argument
28
+ validation and the machine-readable dump are all generated from it.
29
+
30
+ ```bash
31
+ zumino # every command, grouped, one line each
32
+ zumino help task list # one command in full: flags, accepted values, examples
33
+ zumino commands # every command as a flat list, for grepping
34
+ zumino commands --json # the whole surface, for a program to read
35
+ ```
36
+
37
+ `zumino commands --json` is the one to reach for from a script or an agent. It
38
+ carries the vocabularies, so `--status in_review` is never discovered by having
39
+ `--status review` refused:
40
+
41
+ ```bash
42
+ zumino commands --json | jq -r '.values.taskStatus[]'
43
+ zumino commands --json | jq -r '.commands[] | select(.path|startswith("task")) | .usage'
44
+ ```
45
+
46
+ ## The shape of it
47
+
48
+ **Generic reads span every kind; every write names its kind.** So `queue`,
49
+ `context` and `find` are verbs at the root, and everything that touches one kind
50
+ hangs off that kind's noun.
25
51
 
26
52
  ```
27
- zumino queue [--needs-input] [--limit N] what to work on next
28
- zumino context <CODE> [--activity] the whole brief for one task
29
- zumino find <query> [--kind k] one read across every kind
30
-
31
- zumino task create --title T [--description D] [--epic N]
32
- zumino task status <CODE> <status>
33
- zumino task assign <CODE> <userId|->
34
- zumino task spec <CODE> --plan T | --acceptance T
35
- zumino task comment <CODE> <text>
36
- zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE|project#N>
37
- zumino task ref <CODE> --url URL [--title T]
38
- zumino task attention <CODE> [reason|-]
39
- zumino task show <CODE>
40
-
41
- zumino request create|answer|status|note|comment|show
42
- zumino request promote <CODE|#N> --to <work-project>
43
- zumino epic create|status|list|show # address as E6 or ACME-E6
44
-
45
- zumino api <METHOD> <PATH> [--body JSON]
46
- zumino auth login | status | list | logout
47
- zumino init
48
- zumino skill install [--dir D] [--check]
49
- zumino self-update
53
+ orient workspace list · project list · project show · project tags · auth status
54
+ read queue · context <CODE> · find
55
+ one kind task list show events create edit status assign spec comment link ref attention tag untag
56
+ request list show events create edit answer status note comment promote tag untag
57
+ epic list show create edit status
58
+ else api · auth · init · skill install · self-update · commands · help
50
59
  ```
51
60
 
61
+ ## Filters
62
+
63
+ Every list command carries the filters its endpoint performs, and **only** those.
64
+ Nothing here narrows a list locally: a client-side filter reads exactly like a
65
+ server-side one and is wrong invisibly, because it filters the page it was handed
66
+ rather than the project.
67
+
68
+ ```bash
69
+ zumino task list --status todo,in_progress --priority high --assignee me
70
+ zumino task list --epic E3 --sort manual # the order the board draws
71
+ zumino task list --status done --limit 100 --offset 100
72
+ zumino request list --status open --sort top --limit 10
73
+ zumino find "rate limit" --kind task,epic --state open
74
+ zumino task events ACME-14 --field status # how has this moved
75
+ ```
76
+
77
+ Three consequences of that rule worth knowing:
78
+
79
+ - **A flag a command does not accept is an error**, not a no-op. `zumino find
80
+ --priority high` says so and names the command that does filter by priority.
81
+ Every value is checked against the real vocabulary before the call is made.
82
+ - **`--status` is repeatable** (`--status todo --status done`) or
83
+ comma-separated; both produce one query parameter.
84
+ - **A paged answer says it is paged**, and names the flag that fetches the rest —
85
+ `1–50 of 312 — next page: --offset 50`. A table that stops at fifty rows and
86
+ says nothing is how a duplicate gets filed.
87
+
88
+ `--assignee me` resolves against the token's owner, so no `jq` is needed to find
89
+ your own user id.
90
+
52
91
  `--json` prints the raw response instead of a table, on every command that
53
92
  returns API data. The housekeeping commands — `auth login`/`logout`, `init`,
54
93
  `skill install`, `self-update` — report progress in prose and ignore it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zumino/cli",
3
- "version": "2.1.0",
3
+ "version": "2.2.0",
4
4
  "description": "Drive Zumino from a terminal, a script, or an agent.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/skill/SKILL.md CHANGED
@@ -1,14 +1,27 @@
1
1
  ---
2
2
  name: zumino
3
- version: 2.1.0
3
+ version: 2.2.0
4
4
  description: Read and modify Zumino projects from any directory with the `zumino` CLI. Two kinds of project — feedback projects (requests, votes, roadmap statuses, read publicly as a board) and work projects (tasks and epics, with a structured spec, an owner, and a readiness bar) — plus one generic read across every kind of item. Use whenever the user mentions Zumino, a product board, a public roadmap, requests or feedback, a project, a task, an epic, a backlog item, a spec, or asks to file/track/pick up a bug, feature request, idea, or piece of work.
5
5
  ---
6
6
 
7
7
  # Zumino — projects from the command line
8
8
 
9
- Everything is `zumino`, installed globally and working from any directory. Run
10
- `zumino --help` for the full surface; this file is the part `--help` cannot
11
- carry — **what the words mean, and what will go wrong if you assume.**
9
+ Everything is `zumino`, installed globally and working from any directory.
10
+
11
+ **The surface documents itself, so this file does not restate it.**
12
+
13
+ ```bash
14
+ zumino # every command, grouped, one line each
15
+ zumino help task list # one command in full: flags, accepted values, examples
16
+ zumino commands --json # the whole surface, for a program to read
17
+ ```
18
+
19
+ `zumino commands --json` is the one to use. It carries every command, every flag
20
+ and — the part worth having — **every value each filter accepts**, under
21
+ `.values`. Read it rather than guessing a status and reading a 400 back.
22
+
23
+ This file is the part none of that can carry: **what the words mean, and what
24
+ will go wrong if you assume.**
12
25
 
13
26
  If `zumino` is not installed: `npm i -g @zumino/cli`.
14
27
 
@@ -31,10 +44,16 @@ zumino api POST /workspaces/acme/projects/app/tasks --body '{"title":"…"}'
31
44
 
32
45
  ```bash
33
46
  zumino auth status # who am I, against which host, from which source
47
+ zumino project list # what can I see, and which kind is each one
34
48
  zumino queue # what can I pick up right now
35
49
  zumino context ONS-14 # the whole brief for one task, as markdown
36
50
  ```
37
51
 
52
+ `zumino project list` is the orientation read, and its TYPE column decides which
53
+ half of the CLI applies: a **feedback** project holds requests (`#42`,
54
+ `zumino request …`), a **work** project holds tasks and epics (`ONS-14`,
55
+ `zumino task …`). `zumino workspace list` is the same question one level up.
56
+
38
57
  `zumino context` is not a convenience. It assembles the epic, the description,
39
58
  the plan, the acceptance criteria, the blockers and the requests a task answers
40
59
  into one document. Six separate reads would let you lay them out yourself, and
@@ -42,7 +61,11 @@ that is exactly the failure: you would assemble a *plausible* brief instead of
42
61
  the real one.
43
62
 
44
63
  Add `--activity` when picking up work someone (or some earlier run) already
45
- touched — it appends what has been tried.
64
+ touched — it appends what has been tried. `zumino task events ONS-14` is the same
65
+ history in full, and `--field status` narrows it to how the task has moved.
66
+ **Read it before picking a task up**: "what was tried and then reverted" is the
67
+ one piece of context the brief cannot hold, and re-doing an abandoned approach is
68
+ what it costs to skip.
46
69
 
47
70
  ## Read the domain model first
48
71
 
@@ -53,10 +76,17 @@ It is the one place the vocabulary is written down, and the registries in
53
76
 
54
77
  This file deliberately carries **no copy** of any status list or kind list. A
55
78
  second list is accurate the day it is written and wrong a release later, and you
56
- would have no way of telling which one you were reading. From outside the repo,
57
- `zumino api GET /openapi.json` enumerates every value the API accepts, and an
58
- invalid one is refused with a message naming what was expected. Ask rather than
59
- guess.
79
+ would have no way of telling which one you were reading. From outside the repo:
80
+
81
+ ```bash
82
+ zumino commands --json | jq '.values' # every vocabulary
83
+ zumino commands --json | jq -r '.values.taskStatus[]'
84
+ zumino help task list # the same lists, on the page
85
+ ```
86
+
87
+ The CLI refuses a value outside the real set before it sends anything, and names
88
+ the set in the refusal — so a wrong guess costs a message rather than a write.
89
+ Ask rather than guess.
60
90
 
61
91
  ## Three words mean something narrower here
62
92
 
@@ -89,12 +119,20 @@ finding worth reporting, not a reason to stop:
89
119
  # Committed but not yet shaped — the queue will not offer these.
90
120
  zumino find --state open --json | jq -r '.[] | select(.kind=="task") | .code'
91
121
  zumino task show ONS-14 # readiness is on the task
122
+ ```
92
123
 
93
- `find` takes `--state open|closed`, never `--status`: the read that spans every
94
- kind publishes no per-kind status (`docs/decisions/0003`). A status belongs to a
95
- kind, so ask that kind — `zumino task show`.
124
+ Or ask the task endpoint directly, which has the statuses:
125
+
126
+ ```bash
127
+ zumino task list --status todo,shaping # committed
128
+ zumino task list --status backlog --open # not committed
96
129
  ```
97
130
 
131
+ `find` takes `--state open|closed` and **never `--status`**: the read that spans
132
+ every kind publishes no per-kind status (`docs/decisions/0003`). A status belongs
133
+ to a kind, so ask that kind — `zumino task list` or `zumino request list`. The
134
+ CLI says exactly that if you try, rather than answering an unfiltered list.
135
+
98
136
  Shape one of those and it appears in the queue by itself. **Do not invent a
99
137
  description or a criterion to make a task eligible** — that is laundering an
100
138
  unshaped task as a shaped one, and the whole point of the bar is that it cannot
@@ -131,6 +169,10 @@ zumino task create --title "…" --description "…" # if not
131
169
  zumino task ref ONS-14 --url <the PR you opened> # when you open one
132
170
  ```
133
171
 
172
+ **Check the page count.** A list that is a slice of a bigger answer says so and
173
+ names the flag that fetches the rest — `1–50 of 312 — next page: --offset 50`.
174
+ "Nothing matches" from a read you capped at ten is how a duplicate gets filed.
175
+
134
176
  **Before finishing a piece of work, check the backlog.** A change that resolves,
135
177
  contradicts or partially implements something already filed should say so on
136
178
  that ticket rather than leaving it to be reconciled later.
@@ -210,8 +252,11 @@ cannot wait, stop and say which calls landed and which did not.
210
252
  `in_review` and say what you did.
211
253
  - If a task's readiness is below the bar, **raise attention rather than filling
212
254
  the gap with a plausible guess**: `zumino task attention ONS-14 "…"`.
213
- - If a project is ambiguous or missing, run `zumino api GET /projects` and ask —
214
- do not guess.
255
+ - If a project is ambiguous or missing, run `zumino project list` and ask — do
256
+ not guess.
257
+ - **Change several fields in one call**: `zumino task edit ONS-14 --priority high
258
+ --size L` is one entry in the history and reads as one decision. Five calls
259
+ read as five.
215
260
  - An **assignee is always a person**, even when an agent does the work. There is
216
261
  no agent identity; a token acts as its owner, and that is who the history
217
262
  records.
@@ -146,7 +146,7 @@ function readRaw(promptText, stdin = process.stdin) {
146
146
 
147
147
  export const __testables = { readRaw };
148
148
 
149
- const SUB = { login, status, list, logout };
149
+ export const SUB = { login, status, list, logout };
150
150
 
151
151
  export async function run(args, flags) {
152
152
  const [sub = "status", ...rest] = args;
@@ -0,0 +1,26 @@
1
+ import { VERSION } from "../client.js";
2
+ import { json, out } from "../output.js";
3
+ import { flat, surface } from "../help.js";
4
+ import { VALUES } from "../spec.js";
5
+
6
+ /**
7
+ * `zumino commands [--json]` — the whole surface, in one read.
8
+ *
9
+ * The command an agent runs first. Without it, discovering the CLI means
10
+ * paging through `--help` one command at a time and guessing which values a
11
+ * filter accepts; with `--json` it is a single object carrying every command,
12
+ * every flag, and — the part that matters — every enumerated value, so
13
+ * `--status review` is never sent to find out that the word is `in_review`.
14
+ *
15
+ * It reaches no network and needs no token: the surface is a property of the
16
+ * installed CLI, and the version handshake already says whether that CLI is
17
+ * current.
18
+ */
19
+ export async function run(args, flags) {
20
+ if (flags.json) {
21
+ json(surface({ version: VERSION, values: VALUES }));
22
+ return 0;
23
+ }
24
+ out(flat());
25
+ return 0;
26
+ }
@@ -1,8 +1,10 @@
1
1
  import { api } from "../client.js";
2
2
  import { resolveContext } from "../config.js";
3
3
  import { CliError } from "../errors.js";
4
- import { clip, json, out, pick, table, positiveInt, safeText, givenFlag } from "../output.js";
4
+ import { clip, dim, json, out, pick, safeText, table } from "../output.js";
5
+ import { givenFlag, oneValue } from "../flags.js";
5
6
  import { projectPath, resolveItem } from "../items.js";
7
+ import { resolveUser } from "../users.js";
6
8
 
7
9
  /*
8
10
  * Epics group tasks, and are not tasks.
@@ -11,7 +13,7 @@ import { projectPath, resolveItem } from "../items.js";
11
13
  * so there is deliberately nothing here that nests an epic inside anything.
12
14
  */
13
15
 
14
- const SUB = { create, status, list, show };
16
+ export const SUB = { list, show, create, edit, status };
15
17
 
16
18
  export async function run(args, flags) {
17
19
  const [sub, ...rest] = args;
@@ -24,38 +26,20 @@ export async function run(args, flags) {
24
26
  return fn(rest, flags);
25
27
  }
26
28
 
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
-
29
+ /**
30
+ * Every epic in the project.
31
+ *
32
+ * **No filters and no paging, because the endpoint has none** epics are the
33
+ * handful of outcomes a project is organised around, not its inventory, and a
34
+ * project with enough of them to need a page has stopped using them as
35
+ * groupings. Narrowing them locally would be a filter that looks like the
36
+ * server's and is not, so the answer to "only the open ones" is `--json | jq`,
37
+ * and the answer to "what is under one" is `zumino task list --epic E3`.
38
+ */
55
39
  async function list(args, flags) {
56
40
  const ctx = resolveContext(flags);
57
41
  const base = await projectPath(ctx);
58
- const res = await api(ctx, "GET", `${base}/epics`, { query: { limit: positiveInt(flags.limit, "limit") } });
42
+ const res = await api(ctx, "GET", `${base}/epics`);
59
43
  const epics = res?.epics ?? [];
60
44
  if (flags.json) return json(epics), 0;
61
45
  if (epics.length === 0) return out("No epics."), 0;
@@ -68,12 +52,13 @@ async function list(args, flags) {
68
52
  ]),
69
53
  { head: ["EPIC", "STATUS", "DONE", "TITLE"] },
70
54
  );
55
+ out(dim("\nThe work under one: zumino task list --epic E<n>"));
71
56
  return 0;
72
57
  }
73
58
 
74
59
  async function show(args, flags) {
75
60
  const [ref] = args;
76
- if (!ref) throw new CliError("zumino epic show <CODE|N>");
61
+ if (!ref) throw new CliError("zumino epic show <E<n>|CODE>");
77
62
  const ctx = resolveContext(flags);
78
63
  const { path } = await resolveItem(ctx, ref, { kind: "epic" });
79
64
  const res = pick(await api(ctx, "GET", path), "epic");
@@ -83,3 +68,64 @@ async function show(args, flags) {
83
68
  if (res.description) out(`\n${safeText(res.description)}`);
84
69
  return 0;
85
70
  }
71
+
72
+ /** The fields an epic write carries, for `create` and for `edit`. */
73
+ async function fields(ctx, flags) {
74
+ /** @type {Record<string, any>} */
75
+ const body = {};
76
+ for (const name of ["title", "description"]) {
77
+ const v = givenFlag(flags[name]);
78
+ if (v !== undefined) body[name] = v;
79
+ }
80
+ const status = oneValue(flags, "status");
81
+ if (status !== undefined) body.status = status;
82
+ const assignee = oneValue(flags, "assignee");
83
+ if (assignee !== undefined) {
84
+ body.assigneeId = await resolveUser(ctx, assignee, { flag: "--assignee", allowClear: true });
85
+ }
86
+ return body;
87
+ }
88
+
89
+ async function create(args, flags) {
90
+ const ctx = resolveContext(flags);
91
+ const title = oneValue(flags, "title") ?? args.join(" ").trim();
92
+ if (!title) throw new CliError("An epic needs a title.", { hint: 'zumino epic create --title "…"' });
93
+ const base = await projectPath(ctx);
94
+ const body = { ...(await fields(ctx, flags)), title };
95
+ const res = pick(await api(ctx, "POST", `${base}/epics`, { body }), "epic");
96
+ if (flags.json) return json(res), 0;
97
+ // An epic shape publishes neither `code` nor `ref` — just a number. `E<n>` is
98
+ // a real address (see `resolveItem`), so what is printed can be fed straight
99
+ // back to `zumino epic show`; the full `KEY-E<n>` form works too and is what
100
+ // `zumino find --kind epic` shows.
101
+ out(`E${res.number} ${safeText(res.title)}`);
102
+ return 0;
103
+ }
104
+
105
+ async function edit(args, flags) {
106
+ const [ref] = args;
107
+ if (!ref) throw new CliError("zumino epic edit <E<n>|CODE> [fields]");
108
+ const ctx = resolveContext(flags);
109
+ const body = await fields(ctx, flags);
110
+ if (Object.keys(body).length === 0) {
111
+ throw new CliError("Nothing to change.", {
112
+ hint: "Name at least one field. See: zumino help epic edit",
113
+ });
114
+ }
115
+ const { path } = await resolveItem(ctx, ref, { kind: "epic" });
116
+ const res = pick(await api(ctx, "PATCH", path, { body }), "epic");
117
+ if (flags.json) return json(res), 0;
118
+ out(`E${res.number} ${Object.keys(body).join(", ")} written`);
119
+ return 0;
120
+ }
121
+
122
+ async function status(args, flags) {
123
+ const [ref, value] = args;
124
+ if (!ref || !value) throw new CliError("zumino epic status <E<n>|CODE> <open|in_progress|done|wont_do>");
125
+ const ctx = resolveContext(flags);
126
+ const { path } = await resolveItem(ctx, ref, { kind: "epic" });
127
+ const res = pick(await api(ctx, "PATCH", path, { body: { status: value } }), "epic");
128
+ if (flags.json) return json(res), 0;
129
+ out(`${ref} ${res.status}`);
130
+ return 0;
131
+ }
@@ -1,60 +1,53 @@
1
- import { api } from "../client.js";
1
+ import { api, resolveWorkspace } from "../client.js";
2
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";
3
+ import { csv, nonNegativeInt, oneValue, positiveInt, searchText } from "../flags.js";
4
+ import { clip, codeOf, dim, json, out, pageLine, table } from "../output.js";
5
+ import { resolveUser } from "../users.js";
9
6
 
10
7
  /**
11
- * `zumino find <query>` — one read across every kind of item.
8
+ * `zumino find` — one read across every kind of item.
9
+ *
10
+ * Scoped to a project when one is resolved, and to the whole workspace when
11
+ * not. It reads the spine shape, which deliberately publishes no `status`
12
+ * (`docs/decisions/0003`) — so the STATE column says open or closed, and
13
+ * anything narrower is that kind's own list: `zumino task list --status
14
+ * in_progress`, `zumino request list --status planned`.
12
15
  *
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.
16
+ * Every flag here is a filter the endpoint performs. `--status` and
17
+ * `--priority` are refused with the command that does do them, declared in
18
+ * `spec.js` — they were the two that used to reach the wire, be ignored, and
19
+ * answer 200 with an unfiltered list.
17
20
  */
18
21
  export async function run(args, flags) {
19
22
  const ctx = resolveContext(flags);
20
23
  const workspace = await resolveWorkspace({ ...ctx, project: ctx.project });
21
- const q = args.join(" ").trim();
22
24
 
23
- const path = ctx.project
24
- ? `/workspaces/${workspace}/projects/${ctx.project}/items`
25
- : `/workspaces/${workspace}/items`;
25
+ const q = searchText(args, flags);
26
+ // `!== undefined` rather than truthiness: `--assignee ""` is given and empty,
27
+ // and a falsy test would drop it and answer an unfiltered list.
28
+ const assignee =
29
+ flags.assignee !== undefined
30
+ ? await resolveUser(ctx, oneValue(flags, "assignee"), { flag: "--assignee" })
31
+ : undefined;
26
32
 
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
- });
33
+ const offset = nonNegativeInt(flags.offset, "offset");
34
+ const query = {
35
+ q,
36
+ kind: csv(flags, "kind"),
37
+ state: oneValue(flags, "state"),
38
+ assignee,
39
+ sort: oneValue(flags, "sort"),
40
+ offset,
41
+ limit: positiveInt(flags.limit, "limit"),
42
+ };
43
+
44
+ // The two scopes are written as two calls rather than one call against a
45
+ // computed path: `test/cli-routes.test.ts` reads these templates out of the
46
+ // source to hold them against the server's own routes, and a path assembled
47
+ // into a variable first is a path nothing checks.
48
+ const res = ctx.project
49
+ ? await api(ctx, "GET", `/workspaces/${workspace}/projects/${ctx.project}/items`, { query })
50
+ : await api(ctx, "GET", `/workspaces/${workspace}/items`, { query });
58
51
 
59
52
  const items = res?.items ?? [];
60
53
  if (flags.json) {
@@ -76,8 +69,6 @@ export async function run(args, flags) {
76
69
  ]),
77
70
  { head: ["CODE", "KIND", "STATE", "TITLE", "PROJECT"] },
78
71
  );
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
- }
72
+ out(dim(`\n${pageLine(res, items.length, offset)}`));
82
73
  return 0;
83
74
  }