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