@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/src/items.js ADDED
@@ -0,0 +1,86 @@
1
+ import { api, resolveWorkspace } from "./client.js";
2
+ import { requireProject } from "./config.js";
3
+ import { CliError } from "./errors.js";
4
+ import { pick } from "./output.js";
5
+
6
+ /*
7
+ * Turning what a person typed into a path the API answers.
8
+ *
9
+ * A code (`ONS-14`) is a **lookup, not an address** — `docs/decisions/0004`. So
10
+ * every command that takes one resolves it once at
11
+ * `GET /workspaces/{workspace}/items/{code}` and then uses the `path` that read
12
+ * hands back. Nothing here builds an item path out of a kind and a number by
13
+ * hand: doing so would put the kind→segment mapping in a second place, and the
14
+ * first place is a registry the server holds.
15
+ */
16
+
17
+ /**
18
+ * Resolve `ONS-14`, or a bare number in the current project.
19
+ *
20
+ * @param {any} ctx
21
+ * @param {string} ref a code, `#14`, or `14`
22
+ * @param {{kind?: "task"|"request"|"epic"}} [opts] kind for the bare-number form
23
+ */
24
+ export async function resolveItem(ctx, ref, opts = {}) {
25
+ const workspace = await resolveWorkspace(ctx);
26
+ const bare = String(ref).replace(/^#/, "");
27
+
28
+ // A number in the current project, with the kind either stated by the caller
29
+ // or carried by a letter. `E6` is the form `zumino epic create` prints, and it
30
+ // resolved to nothing until now: an epic's real code is `ACME-E6`, so `E6`
31
+ // went to the code lookup and 404'd — the identifier the CLI handed back could
32
+ // not be fed to the next command. `docs/decisions/0004` addresses an item by
33
+ // kind and number, and that is what this is.
34
+ const local = /^([Ee])?(\d+)$/.exec(bare);
35
+ if (local) {
36
+ const project = requireProject(ctx);
37
+ const kind = local[1] ? "epic" : (opts.kind ?? "task");
38
+ if (opts.kind && kind !== opts.kind) {
39
+ throw new CliError(`${bare} names an ${kind}, not a ${opts.kind}.`, {
40
+ hint: `Use: zumino ${kind} … ${bare}`,
41
+ });
42
+ }
43
+ const number = local[2];
44
+ const segment = { task: "tasks", request: "requests", epic: "epics" }[kind];
45
+ const path = `/workspaces/${workspace}/projects/${project}/${segment}/${number}`;
46
+ return { path, number: Number(number), kind, project, workspace };
47
+ }
48
+
49
+ const item = pick(
50
+ await api(ctx, "GET", `/workspaces/${workspace}/items/${encodeURIComponent(bare)}`),
51
+ "item",
52
+ );
53
+ if (!item?.path) {
54
+ throw new CliError(`Could not resolve "${ref}".`, {
55
+ hint: "Codes look like ONS-14 or ONS-E3. A bare number, or E<n> for an epic, needs --project.",
56
+ });
57
+ }
58
+ // A code resolves to whatever kind the server says it is, and `done` is a
59
+ // valid status on more than one of them — so without this, `zumino task
60
+ // status ONS-E3 done` would close an *epic* through the task subcommand. The
61
+ // per-kind split exists precisely so a write names what it is writing to.
62
+ if (opts.kind && item.kind !== opts.kind) {
63
+ const article = (k) => (/^[aeiou]/.test(k) ? "an" : "a");
64
+ throw new CliError(
65
+ `${bare} is ${article(item.kind)} ${item.kind}, not ${article(opts.kind)} ${opts.kind}.`,
66
+ {
67
+ hint: `Use: zumino ${item.kind} … ${bare}`,
68
+ },
69
+ );
70
+ }
71
+
72
+ return {
73
+ path: item.path,
74
+ number: item.number,
75
+ kind: item.kind,
76
+ project: item.project?.slug,
77
+ workspace,
78
+ item,
79
+ };
80
+ }
81
+
82
+ /** The project path every per-kind write hangs off. */
83
+ export async function projectPath(ctx) {
84
+ const workspace = await resolveWorkspace(ctx);
85
+ return `/workspaces/${workspace}/projects/${requireProject(ctx)}`;
86
+ }
package/src/main.js ADDED
@@ -0,0 +1,163 @@
1
+ import { parseArgs } from "node:util";
2
+
3
+ import { CliError, EXIT_FAILURE } from "./errors.js";
4
+ import { VERSION } from "./client.js";
5
+ import { bold, dim, note, out } from "./output.js";
6
+
7
+ import * as auth from "./commands/auth.js";
8
+ import * as queue from "./commands/queue.js";
9
+ import * as context from "./commands/context.js";
10
+ import * as find from "./commands/find.js";
11
+ import * as task from "./commands/task.js";
12
+ import * as request from "./commands/request.js";
13
+ import * as epic from "./commands/epic.js";
14
+ import * as raw from "./commands/api.js";
15
+ import * as init from "./commands/init.js";
16
+ import * as skill from "./commands/skill.js";
17
+ import * as selfUpdate from "./commands/self-update.js";
18
+
19
+ /*
20
+ * Argument parsing and dispatch.
21
+ *
22
+ * The command shape is `DOMAIN.md`'s rule, not a style choice: **generic reads
23
+ * span every kind, and every write names its kind.** `queue`, `context` and
24
+ * `find` are top-level because they answer across kinds; everything that writes
25
+ * is `task …`, `request …` or `epic …`, so a fourth kind adds a noun rather
26
+ * than widening a flat verb list every script has already learned.
27
+ */
28
+
29
+ const OPTIONS = {
30
+ // global
31
+ json: { type: "boolean" },
32
+ host: { type: "string" },
33
+ token: { type: "string" },
34
+ project: { type: "string" },
35
+ workspace: { type: "string" },
36
+ account: { type: "string" },
37
+ help: { type: "boolean", short: "h" },
38
+ version: { type: "boolean" },
39
+ limit: { type: "string" },
40
+ // reads
41
+ "needs-input": { type: "boolean" },
42
+ kind: { type: "string" },
43
+ status: { type: "string" },
44
+ state: { type: "string" },
45
+ activity: { type: "boolean" },
46
+ // writes
47
+ title: { type: "string" },
48
+ description: { type: "string" },
49
+ assignee: { type: "string" },
50
+ epic: { type: "string" },
51
+ plan: { type: "string" },
52
+ acceptance: { type: "string" },
53
+ url: { type: "string" },
54
+ body: { type: "string" },
55
+ to: { type: "string" },
56
+ // housekeeping
57
+ dir: { type: "string" },
58
+ check: { type: "boolean" },
59
+ yes: { type: "boolean", short: "y" },
60
+ };
61
+
62
+ const COMMANDS = {
63
+ queue: queue.run,
64
+ context: context.run,
65
+ find: find.run,
66
+ task: task.run,
67
+ request: request.run,
68
+ epic: epic.run,
69
+ api: raw.run,
70
+ auth: auth.run,
71
+ init: init.run,
72
+ skill: skill.run,
73
+ "self-update": selfUpdate.run,
74
+ };
75
+
76
+ const USAGE = `${bold("zumino")} — drive Zumino from a terminal, a script, or an agent.
77
+
78
+ ${bold("Reads")} (across every kind of item)
79
+ zumino queue [--needs-input] [--limit N] what to work on next
80
+ zumino context <CODE> [--activity] the whole brief for one task
81
+ zumino find <query> [--kind request|task|epic] [--state open|closed]
82
+
83
+ ${bold("Writes")} (each names its kind)
84
+ zumino task create --title T [--description D] [--epic N]
85
+ zumino task status <CODE> <status>
86
+ zumino task assign <CODE> <userId|->
87
+ zumino task spec <CODE> --plan TEXT | --acceptance TEXT
88
+ zumino task comment <CODE> <text>
89
+ zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE>
90
+ zumino task ref <CODE> --url URL [--title T]
91
+ zumino request answer <CODE|#N> <text>
92
+ zumino request promote <CODE|#N> --to <work-project>
93
+ zumino request status <CODE|#N> <status>
94
+ zumino request note <CODE|#N> <text>
95
+ zumino request comment <CODE|#N> <text>
96
+ zumino epic create --title T
97
+ zumino epic status <CODE> <status>
98
+
99
+ ${bold("Everything else")}
100
+ zumino api <METHOD> <PATH> [--body JSON] any endpoint, including new ones
101
+ zumino auth login | status | list | logout
102
+ zumino init write .zumino.json for this repo
103
+ zumino skill install [--dir D] [--check] install the agent skill, globally
104
+ zumino self-update
105
+
106
+ ${bold("Context")} is resolved in this order, and ${dim("zumino auth status")} says which won:
107
+ --token/--account → ZUMINO_TOKEN → .zumino.json → repo map → sole account
108
+
109
+ ${bold("Flags")} --json --project P --workspace W --host URL --account NAME
110
+ ${bold("Env")} ZUMINO_TOKEN ZUMINO_URL ZUMINO_PROJECT ZUMINO_WORKSPACE
111
+ ZUMINO_ACCOUNT ZUMINO_NO_UPDATE_CHECK
112
+ ${bold("Exit")} 0 ok · 1 failed · 2 nothing resolved · 3 CLI too old`;
113
+
114
+ /** @param {string[]} argv */
115
+ export async function main(argv) {
116
+ let parsed;
117
+ try {
118
+ parsed = parseArgs({
119
+ args: argv,
120
+ options: OPTIONS,
121
+ allowPositionals: true,
122
+ strict: true,
123
+ });
124
+ } catch (err) {
125
+ note(`zumino: ${err.message}`);
126
+ note("Try: zumino --help");
127
+ return EXIT_FAILURE;
128
+ }
129
+
130
+ const { values: flags, positionals } = parsed;
131
+
132
+ if (flags.version) {
133
+ out(VERSION);
134
+ return 0;
135
+ }
136
+ const [name, ...rest] = positionals;
137
+ if (!name || name === "help" || (flags.help && !name)) {
138
+ out(USAGE);
139
+ return 0;
140
+ }
141
+
142
+ const run = COMMANDS[name];
143
+ if (!run) {
144
+ note(`zumino: unknown command "${name}".`);
145
+ note("Try: zumino --help");
146
+ return EXIT_FAILURE;
147
+ }
148
+
149
+ try {
150
+ return (await run(rest, flags)) ?? 0;
151
+ } catch (err) {
152
+ if (err instanceof CliError) {
153
+ note(`zumino: ${err.message}`);
154
+ // The hint is the actionable half, and it is why failures are phrased as
155
+ // imperatives with an exact command: an agent reads this on stderr and
156
+ // runs it, which is the whole mechanism by which drift gets fixed.
157
+ if (err.hint) note(` ${err.hint}`);
158
+ return err.exitCode;
159
+ }
160
+ note(`zumino: ${err?.message ?? String(err)}`);
161
+ return EXIT_FAILURE;
162
+ }
163
+ }
package/src/output.js ADDED
@@ -0,0 +1,192 @@
1
+ import { CliError } from "./errors.js";
2
+ import { styleText } from "node:util";
3
+
4
+ /*
5
+ * Everything the CLI prints, and the one rule that governs all of it:
6
+ *
7
+ * **stdout is the answer; stderr is everything else.** A caller pipes stdout
8
+ * into `jq`, so a version notice, a progress line or a warning written there
9
+ * would corrupt output that was otherwise machine-readable. There is no case in
10
+ * which a notice is worth breaking a pipe for.
11
+ */
12
+
13
+ const colorEnabled = () =>
14
+ process.stdout.isTTY && !process.env.NO_COLOR && process.env.TERM !== "dumb";
15
+
16
+ /** @param {string} s @param {Parameters<typeof styleText>[0]} style */
17
+ function paint(s, style) {
18
+ return colorEnabled() ? styleText(style, s) : s;
19
+ }
20
+
21
+ export const dim = (s) => paint(s, "dim");
22
+ export const bold = (s) => paint(s, "bold");
23
+ export const amber = (s) => paint(s, "yellow");
24
+
25
+ /*
26
+ * The writers do NOT sanitise, and that is deliberate — it was tried and it was
27
+ * wrong in both directions.
28
+ *
29
+ * `bold()` and `dim()` emit CSI sequences, and they run *before* the string
30
+ * reaches the sink, so a sanitising `out()` deleted the CLI's own styling:
31
+ * `--help` rendered plain and every dim hint disappeared. And `json()` goes
32
+ * through `out()` too, so it mutated the answer — `JSON.stringify` escapes C0 as
33
+ * `\uXXXX` but emits DEL and the C1 range raw, which the final rule then
34
+ * stripped from inside string literals, silently returning less than the API
35
+ * holds.
36
+ *
37
+ * The boundary is the untrusted **value**, not the assembled line. Everything
38
+ * read from a response or from a committed `.zumino.json` goes through
39
+ * `safeText` where it is interpolated — see the response fields in the command
40
+ * modules, and the refusal messages in `config.js` and `client.js`, which are
41
+ * the error path an earlier round found unguarded.
42
+ */
43
+
44
+ /** A notice for a person, never part of the answer. Always stderr. */
45
+ export function note(s) {
46
+ process.stderr.write(s + "\n");
47
+ }
48
+
49
+ /** The answer. Always stdout. */
50
+ export function out(s) {
51
+ process.stdout.write(s + "\n");
52
+ }
53
+
54
+ export function json(value) {
55
+ out(JSON.stringify(value, null, 2));
56
+ }
57
+
58
+ /**
59
+ * A plain column table.
60
+ *
61
+ * Columns are padded to their widest cell, which is fine for the sizes a queue
62
+ * or a search answers with and avoids a dependency for the sake of box-drawing.
63
+ * `--json` exists for anything that wants to be parsed, so this is free to be
64
+ * for eyes only.
65
+ *
66
+ * @param {Array<Array<string>>} rows
67
+ * @param {{head?: string[]}} [opts]
68
+ */
69
+ export function table(rows, opts = {}) {
70
+ const all = opts.head ? [opts.head, ...rows] : rows;
71
+ if (all.length === 0) return;
72
+ const widths = [];
73
+ for (const row of all) {
74
+ row.forEach((cell, i) => {
75
+ widths[i] = Math.max(widths[i] ?? 0, String(cell ?? "").length);
76
+ });
77
+ }
78
+ const render = (row) =>
79
+ row
80
+ .map((cell, i) =>
81
+ i === row.length - 1
82
+ ? String(cell ?? "")
83
+ : String(cell ?? "").padEnd(widths[i]),
84
+ )
85
+ .join(" ")
86
+ .trimEnd();
87
+ if (opts.head) out(dim(render(opts.head)));
88
+ for (const row of rows) out(render(row));
89
+ }
90
+
91
+ /**
92
+ * Strip terminal control sequences from text the server sent.
93
+ *
94
+ * Item titles and bodies are written by other people — on a public feedback
95
+ * project, by anyone who can reach it — and they land on a terminal when
96
+ * somebody runs `zumino find` or `zumino task show`. Left intact, an ESC in a
97
+ * title is *interpreted*: CSI can clear the screen or forge the rest of the
98
+ * listing, and OSC 52 can replace the reader's clipboard. `JSON.parse` restores
99
+ * whatever escaping the transport applied, so the bytes arrive live.
100
+ *
101
+ * Everything printed from a response goes through here. Tabs and newlines are
102
+ * kept — they are legitimate in a description and in the context brief — and
103
+ * every other C0/C1 control, plus complete ANSI sequences, is removed.
104
+ */
105
+ export function safeText(value) {
106
+ return String(value ?? "")
107
+ // CSI / OSC / other escape sequences, taken whole so no orphan tail is left
108
+ .replace(/\u001B\[[0-?]*[ -/]*[@-~]/g, "")
109
+ .replace(/\u001B\][^\u0007\u001B]*(?:\u0007|\u001B\\)/g, "")
110
+ .replace(/\u001B[@-Z\\-_]/g, "")
111
+ // remaining C0 (keeping \t \n \r) and C1
112
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "");
113
+ }
114
+
115
+ /** Trim a title to keep a table one line per row on an ordinary terminal. */
116
+ export function clip(s, n = 60) {
117
+ const t = safeText(s).replace(/\s+/g, " ").trim();
118
+ return t.length <= n ? t : t.slice(0, n - 1) + "…";
119
+ }
120
+
121
+ /**
122
+ * An item's code, under whichever word the endpoint published it.
123
+ *
124
+ * The API says `code` on the generic spine read and `ref` on every task shape,
125
+ * for the same string — `tasks/the-api-publishes-an-item-code-under-two-words.md`.
126
+ * `DOMAIN.md` settles the word as **code**, so the CLI reads past the drift and
127
+ * always prints one. This is the only place that knows both spellings.
128
+ */
129
+ export function codeOf(item) {
130
+ return item?.code ?? item?.ref ?? "";
131
+ }
132
+
133
+ /**
134
+ * The object out of a response envelope.
135
+ *
136
+ * Every write on this API answers `{"task": {…}}`, `{"item": {…}}`,
137
+ * `{"comment": {…}}` and so on rather than the object alone — the envelope is
138
+ * what lets a response grow a second key without changing the shape of the
139
+ * first. The CLI unwraps at the edge so nothing downstream has to know which
140
+ * word a given endpoint chose, and falls back to the response itself so a bare
141
+ * body is not a crash.
142
+ */
143
+ export function pick(res, key) {
144
+ return res?.[key] ?? res;
145
+ }
146
+
147
+ /**
148
+ * A workspace slug, however the endpoint published it.
149
+ *
150
+ * `GET /queue` sets `workspace` to a plain slug string; `GET /projects` sets it
151
+ * to a `{slug, name}` reference (`publicWorkspaceRefView`). Both feed paths that
152
+ * name their workspace, and interpolating the object yields
153
+ * `/workspaces/[object%20Object]/…`, which 404s — so every read of the field
154
+ * goes through here.
155
+ */
156
+ export function workspaceSlug(value) {
157
+ return typeof value === "string" ? value : (value?.slug ?? null);
158
+ }
159
+
160
+ /**
161
+ * A flag that must be a positive integer, refused rather than coerced.
162
+ *
163
+ * `Number("foo")` is `NaN`, and `JSON.stringify({n: NaN})` is `{"n":null}` —
164
+ * which on `epicNumber` is the value that *detaches* the epic. So a typo in
165
+ * `--epic` silently did the opposite of what was asked. A bad `--limit` was
166
+ * milder (the server falls back to its default) but equally silent.
167
+ */
168
+ export function positiveInt(value, flag) {
169
+ if (value === undefined || value === null || value === "") return undefined;
170
+ const n = Number(String(value).trim());
171
+ if (!Number.isInteger(n) || n < 1) {
172
+ throw new CliError(`--${flag} takes a positive whole number, not "${value}".`);
173
+ }
174
+ return n;
175
+ }
176
+
177
+ /**
178
+ * An explicitly-given flag value, where empty means "clear it", not "absent".
179
+ *
180
+ * `parseArgs` reports `--description ""` and `--description=` as `""`, which is
181
+ * falsy — so a truthiness gate dropped the flag and the field was left untouched
182
+ * instead of cleared. `config.js` already states the rule for credentials ("an
183
+ * explicit empty value is an error, not an absence"); this is the same
184
+ * distinction for fields where empty is a legitimate instruction rather than a
185
+ * mistake.
186
+ *
187
+ * Returns `undefined` when the flag was not given at all, so a caller can tell
188
+ * the three cases apart.
189
+ */
190
+ export function givenFlag(value) {
191
+ return value === undefined ? undefined : String(value);
192
+ }