@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.
package/README.md ADDED
@@ -0,0 +1,108 @@
1
+ # `@zumino/cli`
2
+
3
+ Drive [Zumino](https://zumino.cc) from a terminal, a script, or an agent.
4
+
5
+ ```bash
6
+ npm i -g @zumino/cli
7
+ zumino auth login
8
+ zumino init # in a repo — ties it to a project
9
+ zumino queue
10
+ ```
11
+
12
+ ## Why a CLI and not a library
13
+
14
+ There is nothing to import. The API is the product, and this is a thin front-end
15
+ over it — every endpoint stays reachable through `zumino api`, including ones
16
+ added after you installed this.
17
+
18
+ What the CLI adds that `curl` cannot is **a version handshake**. Every response
19
+ carries the oldest CLI the server will answer and the newest one published, so a
20
+ stale client is told on the call it was already making, and an incompatible one
21
+ refuses to run rather than returning a plausible answer built on shapes the
22
+ server no longer sends.
23
+
24
+ ## Commands
25
+
26
+ ```
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
50
+ ```
51
+
52
+ `--json` prints the raw response instead of a table, on every command that
53
+ returns API data. The housekeeping commands — `auth login`/`logout`, `init`,
54
+ `skill install`, `self-update` — report progress in prose and ignore it.
55
+
56
+ ## Where context comes from
57
+
58
+ Resolved in this order. `zumino auth status` says which rung answered.
59
+
60
+ ```
61
+ 1. --token / --account / --host / --project
62
+ 2. ZUMINO_TOKEN / ZUMINO_URL / ZUMINO_PROJECT / ZUMINO_WORKSPACE / ZUMINO_ACCOUNT
63
+ 3. .zumino.json in the repo committed; host, workspace, project, no secret
64
+ 4. the repo map in ~/.config/zumino/config.json which account you use here
65
+ 5. the only account configured
66
+ 6. refuse
67
+ ```
68
+
69
+ **There is no "current account" and no `switch` command.** One person may hold
70
+ several accounts on several hosts, and two agents may run in two checkouts at
71
+ once — a single mutable pointer in the home directory would let either silently
72
+ change the other's identity, and a tracker write landing as the wrong person
73
+ looks exactly like success.
74
+
75
+ ## Exit codes
76
+
77
+ | code | meaning |
78
+ |---|---|
79
+ | `0` | fine |
80
+ | `1` | the call failed |
81
+ | `2` | nothing resolved an account, host or project |
82
+ | `3` | this CLI is too old for the server — run `zumino self-update` |
83
+
84
+ ## Environment
85
+
86
+ | | |
87
+ |---|---|
88
+ | `ZUMINO_TOKEN` | a personal access token, `zumino_live_…` |
89
+ | `ZUMINO_URL` | host origin, no trailing slash and no `/api` |
90
+ | `ZUMINO_PROJECT` / `ZUMINO_WORKSPACE` | slugs |
91
+ | `ZUMINO_ACCOUNT` | a name from `zumino auth list` |
92
+ | `ZUMINO_NO_UPDATE_CHECK` | silences the "you are behind" notice. It cannot silence the refusal |
93
+
94
+ Notices go to stderr and the answer goes to stdout, so `zumino queue --json \| jq`
95
+ is always safe.
96
+
97
+ ## The agent skill
98
+
99
+ ```bash
100
+ zumino skill install # ~/.claude/skills/zumino/ — once, globally
101
+ ```
102
+
103
+ Installed globally on purpose: a per-repo copy is N copies of one document, each
104
+ aging separately with nothing to say which is current.
105
+
106
+ ## Requirements
107
+
108
+ Node 24 or newer. No runtime dependencies.
package/bin/zumino.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from "../src/main.js";
3
+
4
+ // The CLI owns its own exit code (see `src/errors.js`), so nothing here maps
5
+ // a thrown value to a status — `main` has already decided, and rethrowing would
6
+ // replace a deliberate 3 with node's 1.
7
+ process.exitCode = await main(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@zumino/cli",
3
+ "version": "2.1.0",
4
+ "description": "Drive Zumino from a terminal, a script, or an agent.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "zumino": "bin/zumino.mjs"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "src",
13
+ "skill",
14
+ "README.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=24"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/skkap/zumino.git",
22
+ "directory": "cli"
23
+ },
24
+ "keywords": [
25
+ "zumino",
26
+ "issue-tracker",
27
+ "agents",
28
+ "cli"
29
+ ],
30
+ "scripts": {
31
+ "test": "node --test"
32
+ }
33
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,232 @@
1
+ ---
2
+ name: zumino
3
+ version: 2.1.0
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
+ ---
6
+
7
+ # Zumino — projects from the command line
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.**
12
+
13
+ If `zumino` is not installed: `npm i -g @zumino/cli`.
14
+
15
+ ## The command shape, in one line
16
+
17
+ **Generic reads span every kind; every write names its kind.** `zumino queue`,
18
+ `zumino context ONS-14` and `zumino find` answer across tasks, requests and
19
+ epics. Anything that writes is `zumino task …`, `zumino request …` or
20
+ `zumino epic …`. That is the API's own rule, and the CLI mirrors it so that a
21
+ new kind of item adds a noun rather than changing what you already know.
22
+
23
+ Anything not in the verb layer is still reachable:
24
+
25
+ ```bash
26
+ zumino api GET /openapi.json # every endpoint and every accepted value
27
+ zumino api POST /workspaces/acme/projects/app/tasks --body '{"title":"…"}'
28
+ ```
29
+
30
+ ## Start here, every session
31
+
32
+ ```bash
33
+ zumino auth status # who am I, against which host, from which source
34
+ zumino queue # what can I pick up right now
35
+ zumino context ONS-14 # the whole brief for one task, as markdown
36
+ ```
37
+
38
+ `zumino context` is not a convenience. It assembles the epic, the description,
39
+ the plan, the acceptance criteria, the blockers and the requests a task answers
40
+ into one document. Six separate reads would let you lay them out yourself, and
41
+ that is exactly the failure: you would assemble a *plausible* brief instead of
42
+ the real one.
43
+
44
+ Add `--activity` when picking up work someone (or some earlier run) already
45
+ touched — it appends what has been tried.
46
+
47
+ ## Read the domain model first
48
+
49
+ **If you are in the Zumino repo, read `DOMAIN.md` before you write anything.**
50
+ It is the one place the vocabulary is written down, and the registries in
51
+ `src/lib` are what the server actually enforces, held in step by
52
+ `test/domain-vocabulary.test.ts`.
53
+
54
+ This file deliberately carries **no copy** of any status list or kind list. A
55
+ 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.
60
+
61
+ ## Three words mean something narrower here
62
+
63
+ Each of these will bite an agent specifically.
64
+
65
+ | Word | Here | Not |
66
+ |---|---|---|
67
+ | **ref** | a **pull request** attached to a task — a URL and a title, nothing more. `zumino task ref ONS-14 --url …` attaches one | *not* the identifier of an item. That is a **code**. The API publishes the code under two spellings (`code` and `ref`) depending on which endpoint you asked; the CLI reads past that and always prints **code**. Say "code" to a person |
68
+ | **key** | the **project key** — the permanent uppercase prefix a code is built from (`ONS`), unique per workspace, and **absent on a feedback project** | *not* the project's slug (the `--project` value), and *not* `publicSlug` (only the `/b/{slug}` page's address) |
69
+ | **backlog** | a task **status**, and a **view** of a project | *not* "everything not yet done". `todo`, `shaping` and the rest are not in the backlog; they are committed work |
70
+
71
+ Two consequences, because both look like bugs otherwise:
72
+
73
+ - **A request on a feedback project has no code.** The project has no key, so
74
+ `#42` is the whole of its name. Do not synthesize one.
75
+ - **Nothing in `backlog` is offered by `zumino queue`.**
76
+
77
+ ## `zumino queue` is computed, not a column
78
+
79
+ The default mode answers with **available work**: a task that is `todo` or
80
+ `shaping`, that meets the bar (a description **and** acceptance criteria), with
81
+ no unfinished task blocking it. All three are computed. There is no status
82
+ anyone drags a card into to publish work here.
83
+
84
+ **An empty queue does not mean there is nothing to do.** It means every
85
+ committed task is either below the bar or waiting on something. That is a
86
+ finding worth reporting, not a reason to stop:
87
+
88
+ ```bash
89
+ # Committed but not yet shaped — the queue will not offer these.
90
+ zumino find --state open --json | jq -r '.[] | select(.kind=="task") | .code'
91
+ zumino task show ONS-14 # readiness is on the task
92
+
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`.
96
+ ```
97
+
98
+ Shape one of those and it appears in the queue by itself. **Do not invent a
99
+ description or a criterion to make a task eligible** — that is laundering an
100
+ unshaped task as a shaped one, and the whole point of the bar is that it cannot
101
+ be satisfied by filler.
102
+
103
+ The bar does **not** require a plan. A task can legitimately arrive with no plan
104
+ and expect you to work the approach out; the brief says so when that is the case.
105
+
106
+ `--needs-input` is the other mode: tasks waiting on you.
107
+
108
+ ## Every repo knows its project
109
+
110
+ A tracker only tells the truth if the work goes through it. The failure mode is
111
+ not that people refuse to file tickets — it is that an agent doing an hour of
112
+ real work in a repo has no reason to know a project exists, so the work happens
113
+ and the board silently falls behind.
114
+
115
+ **Two conventions close that.**
116
+
117
+ **1. A repo declares its project.** `zumino init` writes `.zumino.json` at the
118
+ repo root — host, workspace, project, no secret — and every `zumino` command in
119
+ that checkout then needs no arguments. If you are in a repo with no
120
+ `.zumino.json` and the user is tracking work in Zumino, offer to run it.
121
+
122
+ **2. Real work finds or files a ticket.** Not every edit — a typo fix does not
123
+ need one, and a rule that says otherwise gets ignored wholesale. But **work with
124
+ a shape to it** starts by finding the existing ticket or creating one, and ends
125
+ by closing it, commenting on it, or moving it. The threshold: if you would
126
+ mention it in a standup, it needs a ticket.
127
+
128
+ ```bash
129
+ zumino find "rate limit" # is this already filed?
130
+ zumino task create --title "…" --description "…" # if not
131
+ zumino task ref ONS-14 --url <the PR you opened> # when you open one
132
+ ```
133
+
134
+ **Before finishing a piece of work, check the backlog.** A change that resolves,
135
+ contradicts or partially implements something already filed should say so on
136
+ that ticket rather than leaving it to be reconciled later.
137
+
138
+ ## Addressing an epic
139
+
140
+ `zumino epic create` prints `E6`. That is a real address in the current project —
141
+ `zumino epic show E6` works — and so is the full code `ACME-E6`, which is what
142
+ `zumino find --kind epic` shows. A bare `6` means *task* 6, because task and epic
143
+ numbers are separate namespaces, so the letter is what tells them apart.
144
+
145
+ ## The three pieces of writing
146
+
147
+ A task carries a `description` (what this is), `spec.plan` (the output of
148
+ shaping) and `spec.acceptance` (when it is done). The two spec sections are
149
+ written **one at a time**, and the CLI enforces it:
150
+
151
+ ```bash
152
+ zumino task spec ONS-14 --plan "…"
153
+ zumino task spec ONS-14 --acceptance "…"
154
+ ```
155
+
156
+ That is not ceremony. An agent redrafting the plan must not be able to touch the
157
+ criteria it will be judged against.
158
+
159
+ ## Linking work
160
+
161
+ ```bash
162
+ zumino task link ONS-14 blocks ONS-9 # ONS-9 waits on ONS-14
163
+ zumino task link ONS-14 blocked-by ONS-9 # the other direction
164
+ zumino task link ONS-14 related ONS-3 # worth reading together, nothing waits
165
+ zumino task link ONS-14 answers feedback#42 # this work exists because of that request
166
+ ```
167
+
168
+ `answers` is the useful one and the odd one: the far end is a **request**, not a
169
+ task, and it needs owner or admin on the request's project. A request is never
170
+ *converted* into a task — promoting one creates a task that answers it, and both
171
+ go on living:
172
+
173
+ ```bash
174
+ zumino request promote 42 --project feedback --to app
175
+ ```
176
+
177
+ `--project` is where the request lives, `--to` is where the task goes. They are
178
+ two different projects, which is why they are two different flags.
179
+
180
+ ## Errors
181
+
182
+ Failures carry a stable key and an English message; the CLI prints the message
183
+ and, where there is one, a second line telling you what to do. Act on that line.
184
+
185
+ **Exit codes are the contract**, so branch on them rather than parsing text:
186
+
187
+ | code | meaning |
188
+ |---|---|
189
+ | `0` | fine |
190
+ | `1` | the call failed — the message says why |
191
+ | `2` | nothing resolved an account, host or project |
192
+ | `3` | **this CLI is too old for the server** — run `zumino self-update` and retry |
193
+
194
+ **A rate-limit refusal means wait and repeat that exact call — never skip it and
195
+ never shorten it.** The message carries the exact number of seconds. A
196
+ half-authored epic looks finished, which is worse than one not started. If you
197
+ cannot wait, stop and say which calls landed and which did not.
198
+
199
+ ## Rules
200
+
201
+ - **Never destroy anything.** There is no `zumino delete`, deliberately: an
202
+ item's history cascades with the row, so deletion is the one operation that
203
+ erases its own evidence. Retire work with `wont_do`.
204
+ - **Never write text in the user's name without showing it first.** Requests and
205
+ comments on a public board are visible to everyone.
206
+ - **Never overwrite a task's `description` or its `acceptance`** without being
207
+ asked. Those are the human's half: what the work is, and what it will be
208
+ judged against.
209
+ - **Never mark a task `done`.** Acceptance is a person's call. Move it to
210
+ `in_review` and say what you did.
211
+ - If a task's readiness is below the bar, **raise attention rather than filling
212
+ 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.
215
+ - An **assignee is always a person**, even when an agent does the work. There is
216
+ no agent identity; a token acts as its owner, and that is who the history
217
+ records.
218
+ - Titles are one line and specific. Descriptions carry repro steps, file paths
219
+ and links. Do not pad.
220
+
221
+ ## When something says you are out of date
222
+
223
+ ```
224
+ zumino: CLI 2.0.0 is too old for https://zumino.cc, which requires >= 2.1.0.
225
+ Run: zumino self-update
226
+ ```
227
+
228
+ Run it, then retry what you were doing. The CLI refuses rather than guessing
229
+ because a client built against shapes the server no longer sends would produce a
230
+ *plausible* answer, and a plausible answer is worse than none.
231
+
232
+ If the CLI is newer than this skill, refresh it: `zumino skill install`.
package/src/client.js ADDED
@@ -0,0 +1,247 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { createRequire } from "node:module";
5
+
6
+ import { CliError, tooOld } from "./errors.js";
7
+ import { note, safeText, workspaceSlug } from "./output.js";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ export const VERSION = require("../package.json").version;
11
+
12
+ /*
13
+ * One HTTP call, and the version handshake that rides on its reply.
14
+ *
15
+ * The handshake is the reason this CLI is worth having over `curl`. Every
16
+ * `/api/v1` response carries `X-Zumino-Cli-Min` and `X-Zumino-Cli-Latest`
17
+ * (`src/lib/cli-version.ts`), so staleness is detected on the call the caller
18
+ * was already making — no version endpoint, no extra round trip, and no way to
19
+ * be silently out of date.
20
+ *
21
+ * Two tiers, and the difference between them is the whole design:
22
+ *
23
+ * - **below the minimum** — fatal, every time, uncached. The shapes this CLI
24
+ * was built against are no longer what the server sends, so a plausible
25
+ * answer is worse than no answer.
26
+ * - **merely behind** — one line on stderr, at most once a day per host. An
27
+ * agent making forty calls in a session must not read the same notice forty
28
+ * times; that is context it pays for and cannot act on twice.
29
+ */
30
+
31
+ /** @param {string} host */
32
+ function noticeCachePath() {
33
+ const base = process.env.XDG_CACHE_HOME || join(homedir(), ".cache");
34
+ return join(base, "zumino", "update-notice.json");
35
+ }
36
+
37
+ const DAY_MS = 24 * 60 * 60 * 1000;
38
+
39
+ /** True at most once per day per (host, version we would recommend). */
40
+ function shouldNag(host, latest) {
41
+ if (process.env.ZUMINO_NO_UPDATE_CHECK) return false;
42
+ const key = `${host}@${latest}`;
43
+ const path = noticeCachePath();
44
+ let seen = {};
45
+ try {
46
+ seen = JSON.parse(readFileSync(path, "utf8"));
47
+ } catch {
48
+ /* no cache yet */
49
+ }
50
+ if (typeof seen[key] === "number" && Date.now() - seen[key] < DAY_MS) return false;
51
+ try {
52
+ seen[key] = Date.now();
53
+ mkdirSync(dirname(path), { recursive: true });
54
+ writeFileSync(path, JSON.stringify(seen));
55
+ } catch {
56
+ // A read-only or absent cache directory must not stop the command. The cost
57
+ // of failing to record is a repeated notice, which is noise, not breakage.
58
+ }
59
+ return true;
60
+ }
61
+
62
+ /**
63
+ * Enforce the floor, advise about the ceiling.
64
+ *
65
+ * `ZUMINO_NO_UPDATE_CHECK` silences the *advice* only. It deliberately cannot
66
+ * suppress the refusal: an environment variable that let a known-incompatible
67
+ * client keep running would defeat the one guarantee this handshake makes.
68
+ */
69
+ export function evaluateVersion(ours, min, latest) {
70
+ if (min && cmp(ours, min) < 0) return { verdict: "refuse", min };
71
+ if (latest && cmp(ours, latest) < 0) return { verdict: "behind", latest };
72
+ return { verdict: "ok" };
73
+ }
74
+
75
+ function checkVersion(res, host) {
76
+ const { verdict, min, latest } = evaluateVersion(
77
+ VERSION,
78
+ res.headers.get("x-zumino-cli-min"),
79
+ res.headers.get("x-zumino-cli-latest"),
80
+ );
81
+ if (verdict === "refuse") throw tooOld(VERSION, min, host);
82
+ if (verdict === "behind" && shouldNag(host, latest)) {
83
+ note(
84
+ `zumino: CLI ${VERSION} is behind ${latest} on ${host}.\n` +
85
+ ` Update with: zumino self-update`,
86
+ );
87
+ }
88
+ }
89
+
90
+ function cmp(a, b) {
91
+ const pa = String(a).split(".").map(Number);
92
+ const pb = String(b).split(".").map(Number);
93
+ for (let i = 0; i < 3; i++) {
94
+ const d = (pa[i] ?? 0) - (pb[i] ?? 0);
95
+ if (d !== 0) return d < 0 ? -1 : 1;
96
+ }
97
+ return 0;
98
+ }
99
+
100
+ /**
101
+ * Call the API.
102
+ *
103
+ * @param {{host: string, token: string}} ctx
104
+ * @param {string} method
105
+ * @param {string} path below `/api/v1`, e.g. `/queue`
106
+ * @param {{query?: Record<string, any>, body?: any, raw?: boolean}} [opts]
107
+ */
108
+ export async function api(ctx, method, path, opts = {}) {
109
+ const url = new URL(`${ctx.host}/api/v1${path}`);
110
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
111
+ if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
112
+ }
113
+
114
+ let res;
115
+ try {
116
+ res = await fetch(url, {
117
+ method,
118
+ headers: {
119
+ Authorization: `Bearer ${ctx.token}`,
120
+ Accept: "application/json",
121
+ "X-Zumino-Cli": VERSION,
122
+ "User-Agent": `zumino-cli/${VERSION} node/${process.versions.node}`,
123
+ ...(opts.body === undefined ? {} : { "Content-Type": "application/json" }),
124
+ },
125
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
126
+ });
127
+ } catch (cause) {
128
+ throw new CliError(`Cannot reach ${ctx.host}: ${cause.message}`, {
129
+ hint: "Check ZUMINO_URL, or the host in .zumino.json.",
130
+ });
131
+ }
132
+
133
+ checkVersion(res, ctx.host);
134
+
135
+ // Two ways a reply legitimately carries nothing, and they have to be taken
136
+ // before the body guards below or those guards fire on every one of them.
137
+ //
138
+ // HEAD is the one that was missed: it carries no body *by definition*, Next
139
+ // auto-implements it from the GET handler, and it is reachable through
140
+ // `zumino api HEAD …`. Skipping only the empty-body check was not enough —
141
+ // the empty string then reached `JSON.parse` and failed the non-JSON guard
142
+ // instead, which is the same defect one line further down.
143
+ if (res.status === 204 || method === "HEAD") {
144
+ if (!res.ok) {
145
+ throw new CliError(`${ctx.host} answered ${res.status}.`, {
146
+ hint: res.status === 401 ? "Token rejected. Check it with: zumino auth status" : undefined,
147
+ });
148
+ }
149
+ return null;
150
+ }
151
+
152
+ const text = await res.text();
153
+
154
+ // No route on this API answers 200 with an empty body — the empty ones are
155
+ // 204, handled above. So a zero-length body is a truncated or intercepted
156
+ // reply, and letting it through as `null` reproduces the same confident-empty
157
+ // failure the non-JSON guard below exists to stop.
158
+ if (!text) {
159
+ throw new CliError(`${ctx.host} answered ${res.status} with an empty body.`, {
160
+ hint: "The reply was truncated or intercepted; nothing was read.",
161
+ });
162
+ }
163
+ let parsed = null;
164
+ try {
165
+ parsed = JSON.parse(text);
166
+ } catch {
167
+ // A non-JSON body from a JSON API means something in front of the app
168
+ // answered — a proxy, an SSO or captive-portal page, a `ZUMINO_URL` one hop
169
+ // short of the app.
170
+ //
171
+ // **This throws whatever the status was.** It used to raise only on `!ok`,
172
+ // so a `200 text/html` fell through as `null` and every caller's
173
+ // `res?.tasks ?? []` turned it into a confident empty answer at exit 0:
174
+ // `zumino queue` said there was nothing to pick up, `zumino find` said
175
+ // nothing matched — which is what an agent reads before filing a duplicate —
176
+ // and `auth status` reported success against a host that authenticated
177
+ // nothing. That is exactly the plausible-answer failure this file's header
178
+ // says the design exists to prevent.
179
+ throw new CliError(`${ctx.host} answered ${res.status} with a non-JSON body.`, {
180
+ hint: "Is ZUMINO_URL pointing at the app rather than a proxy or a login page?",
181
+ });
182
+ }
183
+
184
+ if (!res.ok) {
185
+ const err = parsed?.error;
186
+ // `Retry-After` carries the exact wait (`src/lib/api-resource.ts`), so a
187
+ // caller sleeps the right amount rather than guessing or dropping the write.
188
+ const retry = res.headers.get("retry-after");
189
+ // The server's message is built from its own keys, but a Zod issue can echo
190
+ // a submitted value back, so it is treated as remote text like any other.
191
+ throw new CliError(safeText(err?.message) || `${method} ${path} failed (${res.status}).`, {
192
+ key: err?.key,
193
+ hint:
194
+ res.status === 429 && retry
195
+ ? `Rate limited. Retry after ${retry}s.`
196
+ : res.status === 401
197
+ ? "Token rejected. Check it with: zumino auth status"
198
+ : undefined,
199
+ });
200
+ }
201
+
202
+ return parsed;
203
+ }
204
+
205
+ /**
206
+ * The workspace a project lives in, looked up when nothing named one.
207
+ *
208
+ * `GET /projects` is one of the two deliberately cross-workspace reads, so this
209
+ * is a single call rather than a search. It is a fallback and says so: `zumino
210
+ * init` writes the workspace into `.zumino.json` precisely to make it stop
211
+ * happening on every command.
212
+ *
213
+ * @param {{host: string, token: string, workspace: string|null, project: string|null}} ctx
214
+ */
215
+ export async function resolveWorkspace(ctx) {
216
+ // Normalised rather than returned as-is: a `.zumino.json` written by an older
217
+ // build can hold the object shape, and it would otherwise be interpolated
218
+ // into every path in that checkout.
219
+ const named = workspaceSlug(ctx.workspace);
220
+ if (named) return named;
221
+ if (!ctx.project) {
222
+ throw new CliError("No project, so no workspace to find.", {
223
+ hint: "Pass --project <slug>, or run: zumino init",
224
+ });
225
+ }
226
+ const { projects = [] } = (await api(ctx, "GET", "/projects")) ?? {};
227
+ const hits = projects.filter(
228
+ (p) => p.slug === ctx.project || p.key === ctx.project,
229
+ );
230
+ if (hits.length === 0) {
231
+ throw new CliError(`No project "${ctx.project}" in any workspace you can reach.`, {
232
+ hint: "List what you can see with: zumino api GET /projects",
233
+ });
234
+ }
235
+ if (hits.length > 1) {
236
+ // The slug is unique per workspace, not globally, so this is reachable and
237
+ // guessing would write to the wrong workspace — the exact failure the
238
+ // workspace segment exists to prevent.
239
+ throw new CliError(
240
+ `"${ctx.project}" exists in more than one workspace: ${hits
241
+ .map((h) => workspaceSlug(h.workspace))
242
+ .join(", ")}.`,
243
+ { hint: "Name one with --workspace <slug>." },
244
+ );
245
+ }
246
+ return workspaceSlug(hits[0].workspace);
247
+ }
@@ -0,0 +1,50 @@
1
+ import { api } from "../client.js";
2
+ import { resolveContext } from "../config.js";
3
+ import { CliError } from "../errors.js";
4
+ import { json } from "../output.js";
5
+
6
+ /**
7
+ * `zumino api <METHOD> <PATH> [--body JSON]` — the escape hatch.
8
+ *
9
+ * It is not a fallback for a missing feature; it is what keeps the verb layer
10
+ * small enough to be worth learning. Every one of the API's endpoints is
11
+ * reachable here, including ones added after this CLI was installed — which is
12
+ * why `CLI_MIN` on the server moves only for a *breaking* change and not for a
13
+ * new endpoint. An old CLI can always reach a new path.
14
+ *
15
+ * `GET /openapi.json` enumerates everything, and an invalid value is refused
16
+ * with a message naming what was expected.
17
+ */
18
+ export async function run(args, flags) {
19
+ const [rawMethod, path, ...rest] = args;
20
+ if (!rawMethod || !path) {
21
+ throw new CliError("zumino api <METHOD> <PATH> [--body JSON]", {
22
+ hint: "Paths are below /api/v1, e.g. /queue or /workspaces/acme/items/ONS-14",
23
+ });
24
+ }
25
+ const method = rawMethod.toUpperCase();
26
+ if (!/^(GET|POST|PATCH|PUT|DELETE|HEAD)$/.test(method)) {
27
+ throw new CliError(`"${rawMethod}" is not an HTTP method.`);
28
+ }
29
+ if (!path.startsWith("/")) {
30
+ throw new CliError(`Path must start with "/" — got "${path}".`, {
31
+ hint: "It is relative to /api/v1, so /queue rather than /api/v1/queue.",
32
+ });
33
+ }
34
+
35
+ const rawBody = flags.body ?? rest.join(" ").trim();
36
+ let body;
37
+ if (rawBody) {
38
+ try {
39
+ body = JSON.parse(rawBody);
40
+ } catch (err) {
41
+ throw new CliError(`--body is not valid JSON: ${err.message}`);
42
+ }
43
+ }
44
+
45
+ const ctx = resolveContext(flags);
46
+ const res = await api(ctx, method, path, { body });
47
+ if (res === null) return 0;
48
+ json(res);
49
+ return 0;
50
+ }