@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 +63 -24
- package/package.json +1 -1
- package/skill/SKILL.md +59 -14
- package/src/commands/auth.js +1 -1
- package/src/commands/commands.js +26 -0
- package/src/commands/epic.js +78 -32
- package/src/commands/find.js +41 -50
- package/src/commands/project.js +147 -0
- package/src/commands/queue.js +2 -1
- package/src/commands/request.js +153 -15
- package/src/commands/skill.js +11 -1
- package/src/commands/task.js +280 -75
- package/src/commands/workspace.js +51 -0
- package/src/events.js +104 -0
- package/src/flags.js +257 -0
- package/src/help.js +259 -0
- package/src/main.js +73 -86
- package/src/output.js +17 -29
- package/src/spec.js +1015 -0
- package/src/users.js +38 -0
package/src/commands/task.js
CHANGED
|
@@ -1,18 +1,57 @@
|
|
|
1
1
|
import { api } from "../client.js";
|
|
2
2
|
import { resolveContext } from "../config.js";
|
|
3
3
|
import { CliError } from "../errors.js";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clip,
|
|
6
|
+
codeOf,
|
|
7
|
+
dim,
|
|
8
|
+
json,
|
|
9
|
+
out,
|
|
10
|
+
pageLine,
|
|
11
|
+
pick,
|
|
12
|
+
safeText,
|
|
13
|
+
table,
|
|
14
|
+
} from "../output.js";
|
|
15
|
+
import {
|
|
16
|
+
assertUserRef,
|
|
17
|
+
csv,
|
|
18
|
+
givenFlag,
|
|
19
|
+
nonNegativeInt,
|
|
20
|
+
oneValue,
|
|
21
|
+
positiveInt,
|
|
22
|
+
searchText,
|
|
23
|
+
} from "../flags.js";
|
|
24
|
+
import { listEvents } from "../events.js";
|
|
5
25
|
import { projectPath, resolveItem } from "../items.js";
|
|
26
|
+
import { resolveUser, resolveUserRef } from "../users.js";
|
|
6
27
|
|
|
7
28
|
/*
|
|
8
|
-
*
|
|
29
|
+
* Tasks: the project's work, read and written.
|
|
9
30
|
*
|
|
10
|
-
* Per-kind because `DOMAIN.md` says writes are per-kind: each has its own
|
|
11
|
-
* schema and its own rate-limit bucket, so a run filing twenty-five tasks is
|
|
12
|
-
* never the reason naming the epic they belong to is refused.
|
|
31
|
+
* Per-kind because `DOMAIN.md` says writes are per-kind: each kind has its own
|
|
32
|
+
* Zod schema and its own rate-limit bucket, so a run filing twenty-five tasks is
|
|
33
|
+
* never the reason naming the epic they belong to is refused. The *list* lives
|
|
34
|
+
* here for the same reason — it reads the task endpoint, with the task
|
|
35
|
+
* vocabulary, and publishes priority, readiness and tags, which the read that
|
|
36
|
+
* spans every kind deliberately does not (`docs/decisions/0003`).
|
|
13
37
|
*/
|
|
14
38
|
|
|
15
|
-
const SUB = {
|
|
39
|
+
export const SUB = {
|
|
40
|
+
list,
|
|
41
|
+
show,
|
|
42
|
+
events,
|
|
43
|
+
create,
|
|
44
|
+
edit,
|
|
45
|
+
status,
|
|
46
|
+
assign,
|
|
47
|
+
spec,
|
|
48
|
+
comment,
|
|
49
|
+
link,
|
|
50
|
+
ref,
|
|
51
|
+
attention,
|
|
52
|
+
tag,
|
|
53
|
+
untag,
|
|
54
|
+
};
|
|
16
55
|
|
|
17
56
|
export async function run(args, flags) {
|
|
18
57
|
const [sub, ...rest] = args;
|
|
@@ -25,40 +64,164 @@ export async function run(args, flags) {
|
|
|
25
64
|
return fn(rest, flags);
|
|
26
65
|
}
|
|
27
66
|
|
|
67
|
+
/**
|
|
68
|
+
* `--epic` as the API takes it, in its two places.
|
|
69
|
+
*
|
|
70
|
+
* An **epic** reference in this project, and nothing else. `epicNumber` is
|
|
71
|
+
* project-local, so a key prefix cannot express a foreign epic — its only effect
|
|
72
|
+
* was to let one through silently: `OPS-E14` targeted epic 14 *here*. And
|
|
73
|
+
* stripping leading letters off anything turned the task code `ONS-14` into epic
|
|
74
|
+
* 14, which is legal because task and epic numbers are separate namespaces, so
|
|
75
|
+
* the task was created under the wrong parent and reported success. Accept `E3`
|
|
76
|
+
* or `3`; refuse everything else and say why.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} raw
|
|
79
|
+
* @param {{allowNone: boolean}} opts `none` is a filter value and a detach
|
|
80
|
+
*/
|
|
81
|
+
function epicRef(raw, { allowNone }) {
|
|
82
|
+
const v = String(raw).trim();
|
|
83
|
+
if (allowNone && (v === "none" || v === "-")) return null;
|
|
84
|
+
const m = /^[Ee]?(\d+)$/.exec(v);
|
|
85
|
+
if (!m) {
|
|
86
|
+
throw new CliError(`--epic "${v}" is not an epic in this project.`, {
|
|
87
|
+
hint:
|
|
88
|
+
"Write E3 or 3" +
|
|
89
|
+
(allowNone ? ", or none" : "") +
|
|
90
|
+
". A task code like ONS-14 is not an epic, and an epic in another " +
|
|
91
|
+
"project cannot be a parent — move the task instead.",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return positiveInt(m[1], "epic");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function list(args, flags) {
|
|
98
|
+
const ctx = resolveContext(flags);
|
|
99
|
+
const base = await projectPath(ctx);
|
|
100
|
+
|
|
101
|
+
// `!== undefined`, not truthiness: `--assignee ""` and `--epic ""` are given
|
|
102
|
+
// and empty, and a falsy test drops them into an unfiltered list.
|
|
103
|
+
const assignee =
|
|
104
|
+
flags.assignee !== undefined
|
|
105
|
+
? await resolveUser(ctx, oneValue(flags, "assignee"), { flag: "--assignee" })
|
|
106
|
+
: undefined;
|
|
107
|
+
const epic =
|
|
108
|
+
flags.epic !== undefined ? epicRef(oneValue(flags, "epic"), { allowNone: true }) : undefined;
|
|
109
|
+
const offset = nonNegativeInt(flags.offset, "offset");
|
|
110
|
+
|
|
111
|
+
const res = await api(ctx, "GET", `${base}/tasks`, {
|
|
112
|
+
query: {
|
|
113
|
+
q: searchText(args, flags),
|
|
114
|
+
status: csv(flags, "status"),
|
|
115
|
+
type: oneValue(flags, "type"),
|
|
116
|
+
priority: oneValue(flags, "priority"),
|
|
117
|
+
assignee,
|
|
118
|
+
tag: oneValue(flags, "tag"),
|
|
119
|
+
// `none` is the ungrouped remainder, which no number can express — and it
|
|
120
|
+
// is the one filter on this endpoint that refuses what it cannot parse,
|
|
121
|
+
// because an unfiltered list is indistinguishable from a filter that
|
|
122
|
+
// matched everything.
|
|
123
|
+
epic: epic === null ? "none" : epic,
|
|
124
|
+
needsInput: flags["needs-input"] ? "true" : undefined,
|
|
125
|
+
open: flags.open ? "true" : undefined,
|
|
126
|
+
sort: oneValue(flags, "sort"),
|
|
127
|
+
offset,
|
|
128
|
+
limit: positiveInt(flags.limit, "limit"),
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const tasks = res?.tasks ?? [];
|
|
133
|
+
if (flags.json) return json(tasks), 0;
|
|
134
|
+
if (tasks.length === 0) return out("No tasks match."), 0;
|
|
135
|
+
|
|
136
|
+
table(
|
|
137
|
+
tasks.map((t) => [
|
|
138
|
+
codeOf(t) || `#${t.number}`,
|
|
139
|
+
t.status ?? "",
|
|
140
|
+
t.priority ?? "",
|
|
141
|
+
t.type ?? "",
|
|
142
|
+
safeText(t.assignee?.name) || "—",
|
|
143
|
+
clip(t.title, 48),
|
|
144
|
+
]),
|
|
145
|
+
{ head: ["CODE", "STATUS", "PRIORITY", "TYPE", "ASSIGNEE", "TITLE"] },
|
|
146
|
+
);
|
|
147
|
+
out(dim(`\n${pageLine(res, tasks.length, offset)}`));
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function show(args, flags) {
|
|
152
|
+
const [itemRef] = args;
|
|
153
|
+
if (!itemRef) throw new CliError("zumino task show <CODE>");
|
|
154
|
+
const ctx = resolveContext(flags);
|
|
155
|
+
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
156
|
+
const res = pick(await api(ctx, "GET", path), "task");
|
|
157
|
+
if (flags.json) return json(res), 0;
|
|
158
|
+
out(`${codeOf(res)} ${safeText(res.title)}`);
|
|
159
|
+
out(`status ${res.status} priority ${res.priority ?? "-"} assignee ${safeText(res.assignee?.name) || "-"}`);
|
|
160
|
+
if (res.description) out(`\n${safeText(res.description)}`);
|
|
161
|
+
return 0;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function events(args, flags) {
|
|
165
|
+
const [itemRef] = args;
|
|
166
|
+
if (!itemRef) throw new CliError("zumino task events <CODE>");
|
|
167
|
+
const ctx = resolveContext(flags);
|
|
168
|
+
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
169
|
+
return listEvents(ctx, path, flags);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The fields a task write carries, built once for `create` and for `edit`.
|
|
174
|
+
*
|
|
175
|
+
* `!== undefined`, not truthiness: `--description ""` is an instruction to leave
|
|
176
|
+
* it blank, and dropping it silently left whatever was there. A field nobody
|
|
177
|
+
* named is absent from the body, so a patch touches exactly what was asked for —
|
|
178
|
+
* which is also what makes `edit` one audit group rather than several.
|
|
179
|
+
*
|
|
180
|
+
* @param {any} ctx
|
|
181
|
+
* @param {Record<string, any>} flags
|
|
182
|
+
* @param {{patch: boolean}} opts a patch may clear; a create has nothing to clear
|
|
183
|
+
*/
|
|
184
|
+
async function fields(ctx, flags, { patch }) {
|
|
185
|
+
/** @type {Record<string, any>} */
|
|
186
|
+
const body = {};
|
|
187
|
+
const text = (name) => {
|
|
188
|
+
const v = givenFlag(flags[name]);
|
|
189
|
+
if (v !== undefined) body[name] = v;
|
|
190
|
+
};
|
|
191
|
+
text("title");
|
|
192
|
+
text("description");
|
|
193
|
+
|
|
194
|
+
for (const name of ["type", "status", "priority"]) {
|
|
195
|
+
const v = oneValue(flags, name);
|
|
196
|
+
if (v !== undefined) body[name] = v;
|
|
197
|
+
}
|
|
198
|
+
// Nullable enums: `-` clears them, which is only meaningful on a patch.
|
|
199
|
+
for (const name of ["size", "clarity"]) {
|
|
200
|
+
const v = oneValue(flags, name);
|
|
201
|
+
if (v === undefined) continue;
|
|
202
|
+
body[name] = patch && (v === "-" || v === "") ? null : v;
|
|
203
|
+
}
|
|
204
|
+
for (const [name, key] of [["assignee", "assigneeId"], ["reviewer", "reviewerId"]]) {
|
|
205
|
+
const v = oneValue(flags, name);
|
|
206
|
+
if (v === undefined) continue;
|
|
207
|
+
body[key] = await resolveUser(ctx, v, { flag: `--${name}`, allowClear: true });
|
|
208
|
+
}
|
|
209
|
+
if (flags.epic !== undefined) {
|
|
210
|
+
body.epicNumber = epicRef(oneValue(flags, "epic"), { allowNone: patch });
|
|
211
|
+
}
|
|
212
|
+
return body;
|
|
213
|
+
}
|
|
214
|
+
|
|
28
215
|
async function create(args, flags) {
|
|
29
216
|
const ctx = resolveContext(flags);
|
|
30
|
-
const title = flags
|
|
217
|
+
const title = oneValue(flags, "title") ?? args.join(" ").trim();
|
|
31
218
|
if (!title) {
|
|
32
219
|
throw new CliError("A task needs a title.", {
|
|
33
220
|
hint: 'zumino task create --title "…"',
|
|
34
221
|
});
|
|
35
222
|
}
|
|
36
223
|
const base = await projectPath(ctx);
|
|
37
|
-
const body = { title };
|
|
38
|
-
// `!== undefined`, not truthiness: `--description ""` is an instruction to
|
|
39
|
-
// leave it blank, and dropping it silently left whatever was there.
|
|
40
|
-
if (givenFlag(flags.description) !== undefined) body.description = flags.description;
|
|
41
|
-
if (givenFlag(flags.status) !== undefined) body.status = flags.status;
|
|
42
|
-
// An **epic** reference in this project, and nothing else.
|
|
43
|
-
//
|
|
44
|
-
// `epicNumber` is project-local, so a key prefix cannot express a foreign
|
|
45
|
-
// epic — its only effect was to let one through silently: `OPS-E14` targeted
|
|
46
|
-
// epic 14 *here*. And stripping leading letters off anything turned the task
|
|
47
|
-
// code `ONS-14` into epic 14, which is legal because task and epic numbers are
|
|
48
|
-
// separate namespaces, so the task was created under the wrong parent and
|
|
49
|
-
// reported success. Accept `E3` or `3`; refuse everything else and say why.
|
|
50
|
-
if (givenFlag(flags.epic) !== undefined) {
|
|
51
|
-
const raw = String(flags.epic).trim();
|
|
52
|
-
const m = /^[Ee]?(\d+)$/.exec(raw);
|
|
53
|
-
if (!m) {
|
|
54
|
-
throw new CliError(`--epic "${raw}" is not an epic in this project.`, {
|
|
55
|
-
hint:
|
|
56
|
-
"Write E3 or 3. A task code like ONS-14 is not an epic, and an epic in " +
|
|
57
|
-
"another project cannot be a parent — move the task instead.",
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
body.epicNumber = positiveInt(m[1], "epic");
|
|
61
|
-
}
|
|
224
|
+
const body = { ...(await fields(ctx, flags, { patch: false })), title };
|
|
62
225
|
|
|
63
226
|
const res = pick(await api(ctx, "POST", `${base}/tasks`, { body }), "task");
|
|
64
227
|
if (flags.json) return json(res), 0;
|
|
@@ -66,15 +229,41 @@ async function create(args, flags) {
|
|
|
66
229
|
return 0;
|
|
67
230
|
}
|
|
68
231
|
|
|
232
|
+
/**
|
|
233
|
+
* One PATCH carrying every field that was named.
|
|
234
|
+
*
|
|
235
|
+
* It exists because the alternative was five calls and five audit groups for one
|
|
236
|
+
* decision: the history renders a group as one sentence, so `--priority critical
|
|
237
|
+
* --status in_progress` should read as one move and not two. `status` and
|
|
238
|
+
* `assign` keep their own subcommands — they are the two an agent does
|
|
239
|
+
* constantly, and they want to be three words.
|
|
240
|
+
*/
|
|
241
|
+
async function edit(args, flags) {
|
|
242
|
+
const [itemRef] = args;
|
|
243
|
+
if (!itemRef) throw new CliError("zumino task edit <CODE> [fields]");
|
|
244
|
+
const ctx = resolveContext(flags);
|
|
245
|
+
const body = await fields(ctx, flags, { patch: true });
|
|
246
|
+
if (Object.keys(body).length === 0) {
|
|
247
|
+
throw new CliError("Nothing to change.", {
|
|
248
|
+
hint: "Name at least one field. See: zumino help task edit",
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
252
|
+
const res = pick(await api(ctx, "PATCH", path, { body }), "task");
|
|
253
|
+
if (flags.json) return json(res), 0;
|
|
254
|
+
out(`${codeOf(res)} ${Object.keys(body).join(", ")} written`);
|
|
255
|
+
return 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
69
258
|
async function status(args, flags) {
|
|
70
|
-
const [
|
|
71
|
-
if (!
|
|
259
|
+
const [itemRef, value] = args;
|
|
260
|
+
if (!itemRef || !value) {
|
|
72
261
|
throw new CliError("zumino task status <CODE> <status>", {
|
|
73
|
-
hint: "Statuses are tokens: backlog, shaping, todo, in_progress, done, wont_do.",
|
|
262
|
+
hint: "Statuses are tokens: backlog, shaping, todo, in_progress, in_review, done, wont_do.",
|
|
74
263
|
});
|
|
75
264
|
}
|
|
76
265
|
const ctx = resolveContext(flags);
|
|
77
|
-
const { path } = await resolveItem(ctx,
|
|
266
|
+
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
78
267
|
const res = pick(await api(ctx, "PATCH", path, { body: { status: value } }), "task");
|
|
79
268
|
if (flags.json) return json(res), 0;
|
|
80
269
|
out(`${codeOf(res)} ${res.status}`);
|
|
@@ -82,28 +271,20 @@ async function status(args, flags) {
|
|
|
82
271
|
}
|
|
83
272
|
|
|
84
273
|
async function assign(args, flags) {
|
|
85
|
-
const [
|
|
86
|
-
if (!
|
|
87
|
-
throw new CliError("zumino task assign <CODE> <userId|->", {
|
|
274
|
+
const [itemRef, who] = args;
|
|
275
|
+
if (!itemRef || !who) {
|
|
276
|
+
throw new CliError("zumino task assign <CODE> <userId|me|->", {
|
|
88
277
|
hint: "`-` clears the assignee. An assignee is always a person, even when an agent does the work.",
|
|
89
278
|
});
|
|
90
279
|
}
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
if (who !== "-" && /@/.test(who)) {
|
|
96
|
-
throw new CliError(`"${who}" looks like an email; assign takes a user id.`, {
|
|
97
|
-
hint: "Find one on an item you can read: zumino task show <CODE> --json | jq -r '.assignee.id'",
|
|
98
|
-
});
|
|
99
|
-
}
|
|
100
|
-
|
|
280
|
+
// The shape of the argument is checked before anything is resolved: an email
|
|
281
|
+
// here is wrong whether or not a credential exists, and "No Zumino account is
|
|
282
|
+
// configured" is the less useful of the two answers.
|
|
283
|
+
const person = assertUserRef(who, { flag: "assign", allowClear: true });
|
|
101
284
|
const ctx = resolveContext(flags);
|
|
102
|
-
const
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
"task",
|
|
106
|
-
);
|
|
285
|
+
const assigneeId = await resolveUserRef(ctx, person);
|
|
286
|
+
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
287
|
+
const res = pick(await api(ctx, "PATCH", path, { body: { assigneeId } }), "task");
|
|
107
288
|
if (flags.json) return json(res), 0;
|
|
108
289
|
out(`${codeOf(res)} ${safeText(res.assignee?.name) || "unassigned"}`);
|
|
109
290
|
return 0;
|
|
@@ -115,9 +296,9 @@ async function assign(args, flags) {
|
|
|
115
296
|
* be judged against.
|
|
116
297
|
*/
|
|
117
298
|
async function spec(args, flags) {
|
|
118
|
-
const [
|
|
299
|
+
const [itemRef] = args;
|
|
119
300
|
const section = flags.plan !== undefined ? "plan" : flags.acceptance !== undefined ? "acceptance" : null;
|
|
120
|
-
if (!
|
|
301
|
+
if (!itemRef || !section) {
|
|
121
302
|
throw new CliError("zumino task spec <CODE> --plan TEXT | --acceptance TEXT", {
|
|
122
303
|
hint: "One section per call, on purpose.",
|
|
123
304
|
});
|
|
@@ -128,41 +309,41 @@ async function spec(args, flags) {
|
|
|
128
309
|
});
|
|
129
310
|
}
|
|
130
311
|
const ctx = resolveContext(flags);
|
|
131
|
-
const { path } = await resolveItem(ctx,
|
|
312
|
+
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
132
313
|
const body = section === "plan" ? flags.plan : flags.acceptance;
|
|
133
314
|
const res = pick(
|
|
134
315
|
await api(ctx, "PUT", `${path}/spec/${section}`, { body: { body: body === "" ? null : body } }),
|
|
135
316
|
"task",
|
|
136
317
|
);
|
|
137
318
|
if (flags.json) return json(res), 0;
|
|
138
|
-
out(`${
|
|
319
|
+
out(`${itemRef} ${section} written`);
|
|
139
320
|
return 0;
|
|
140
321
|
}
|
|
141
322
|
|
|
142
323
|
async function comment(args, flags) {
|
|
143
|
-
const [
|
|
324
|
+
const [itemRef, ...text] = args;
|
|
144
325
|
const bodyText = flags.body ?? text.join(" ").trim();
|
|
145
|
-
if (!
|
|
326
|
+
if (!itemRef || !bodyText) throw new CliError("zumino task comment <CODE> <text>");
|
|
146
327
|
const ctx = resolveContext(flags);
|
|
147
|
-
const { path } = await resolveItem(ctx,
|
|
328
|
+
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
148
329
|
const res = pick(await api(ctx, "POST", `${path}/comments`, { body: { body: bodyText } }), "comment");
|
|
149
330
|
if (flags.json) return json(res), 0;
|
|
150
|
-
out(`${
|
|
331
|
+
out(`${itemRef} commented`);
|
|
151
332
|
return 0;
|
|
152
333
|
}
|
|
153
334
|
|
|
154
335
|
const LINK_TYPES = { blocks: "blocks", "blocked-by": "blockedBy", blockedby: "blockedBy", related: "related", answers: "answers" };
|
|
155
336
|
|
|
156
337
|
async function link(args, flags) {
|
|
157
|
-
const [
|
|
338
|
+
const [itemRef, kind, other] = args;
|
|
158
339
|
const type = LINK_TYPES[String(kind ?? "").toLowerCase()];
|
|
159
|
-
if (!
|
|
340
|
+
if (!itemRef || !type || !other) {
|
|
160
341
|
throw new CliError("zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE|project#N>", {
|
|
161
342
|
hint: "`answers` says this work exists because of a request, and takes project#N.",
|
|
162
343
|
});
|
|
163
344
|
}
|
|
164
345
|
const ctx = resolveContext(flags);
|
|
165
|
-
const { path } = await resolveItem(ctx,
|
|
346
|
+
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
166
347
|
// A far end written `project#42` is a request; anything else is a task. The
|
|
167
348
|
// API takes them under different keys because only a request can be answered.
|
|
168
349
|
const body = other.includes("#") && !/^#?\d+$/.test(other)
|
|
@@ -170,7 +351,7 @@ async function link(args, flags) {
|
|
|
170
351
|
: { type, task: other };
|
|
171
352
|
const res = pick(await api(ctx, "POST", `${path}/links`, { body }), "task");
|
|
172
353
|
if (flags.json) return json(res), 0;
|
|
173
|
-
out(`${
|
|
354
|
+
out(`${itemRef} ${type} ${other}`);
|
|
174
355
|
return 0;
|
|
175
356
|
}
|
|
176
357
|
|
|
@@ -185,7 +366,7 @@ async function ref(args, flags) {
|
|
|
185
366
|
const ctx = resolveContext(flags);
|
|
186
367
|
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
187
368
|
const body = { url };
|
|
188
|
-
if (givenFlag(flags.title) !== undefined) body.title = flags.title;
|
|
369
|
+
if (givenFlag(flags.title) !== undefined) body.title = givenFlag(flags.title);
|
|
189
370
|
const res = pick(await api(ctx, "POST", `${path}/refs`, { body }), "ref");
|
|
190
371
|
if (flags.json) return json(res), 0;
|
|
191
372
|
out(`${itemRef} ${url}`);
|
|
@@ -209,15 +390,39 @@ async function attention(args, flags) {
|
|
|
209
390
|
return 0;
|
|
210
391
|
}
|
|
211
392
|
|
|
212
|
-
async function
|
|
213
|
-
|
|
214
|
-
|
|
393
|
+
async function tag(args, flags) {
|
|
394
|
+
return setTag(args, flags, true);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function untag(args, flags) {
|
|
398
|
+
return setTag(args, flags, false);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* A tag on or off, by id.
|
|
403
|
+
*
|
|
404
|
+
* By id and not by name because a palette belongs to a project and names repeat
|
|
405
|
+
* across them — `zumino project tags` is the read that turns one into the other,
|
|
406
|
+
* and it is the only reason that command exists.
|
|
407
|
+
*/
|
|
408
|
+
async function setTag(args, flags, on) {
|
|
409
|
+
const [itemRef, tagId] = args;
|
|
410
|
+
const verb = on ? "tag" : "untag";
|
|
411
|
+
if (!itemRef || !tagId) {
|
|
412
|
+
throw new CliError(`zumino task ${verb} <CODE> <tagId>`, {
|
|
413
|
+
hint: "List the project's tag ids with: zumino project tags",
|
|
414
|
+
});
|
|
415
|
+
}
|
|
215
416
|
const ctx = resolveContext(flags);
|
|
216
417
|
const { path } = await resolveItem(ctx, itemRef, { kind: "task" });
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
418
|
+
// Written as two calls with literal methods rather than one with a ternary:
|
|
419
|
+
// `test/cli-routes.test.ts` reads these call sites out of the source to check
|
|
420
|
+
// each against the server, and a method it cannot read is an endpoint nothing
|
|
421
|
+
// checks.
|
|
422
|
+
const res = on
|
|
423
|
+
? await api(ctx, "PUT", `${path}/tags/${encodeURIComponent(tagId)}`)
|
|
424
|
+
: await api(ctx, "DELETE", `${path}/tags/${encodeURIComponent(tagId)}`);
|
|
425
|
+
if (flags.json) return json(res ?? { ok: true }), 0;
|
|
426
|
+
out(`${itemRef} ${on ? "tagged" : "untagged"}`);
|
|
222
427
|
return 0;
|
|
223
428
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { api } from "../client.js";
|
|
2
|
+
import { resolveContext } from "../config.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { clip, json, note, out, table } from "../output.js";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The workspaces a token can reach.
|
|
8
|
+
*
|
|
9
|
+
* **The bootstrap read.** Every other path on this API carries a `{workspace}`
|
|
10
|
+
* segment, so a client holding a fresh token cannot build a single URL until
|
|
11
|
+
* this one has answered — which is exactly why it was the first thing missing: a
|
|
12
|
+
* CLI with no way to ask "what am I looking at" sends its user to
|
|
13
|
+
* `zumino api GET /workspaces`, and an escape hatch is not an answer to the
|
|
14
|
+
* first question anybody has.
|
|
15
|
+
*
|
|
16
|
+
* There is nothing else here, and that is the API's shape rather than an
|
|
17
|
+
* omission: creating a workspace, membership and roles are governance, and an
|
|
18
|
+
* agent changes the work and not the workspace (`DOMAIN.md` § Rules).
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export const SUB = { list };
|
|
22
|
+
|
|
23
|
+
export async function run(args, flags) {
|
|
24
|
+
const [sub, ...rest] = args;
|
|
25
|
+
const fn = SUB[sub];
|
|
26
|
+
if (!fn) {
|
|
27
|
+
throw new CliError(`zumino workspace: unknown subcommand "${sub ?? ""}".`, {
|
|
28
|
+
hint: `One of: ${Object.keys(SUB).join(", ")}`,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return fn(rest, flags);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function list(args, flags) {
|
|
35
|
+
const ctx = resolveContext(flags);
|
|
36
|
+
const res = await api(ctx, "GET", "/workspaces");
|
|
37
|
+
const workspaces = res?.workspaces ?? [];
|
|
38
|
+
if (flags.json) return json(workspaces), 0;
|
|
39
|
+
|
|
40
|
+
if (workspaces.length === 0) {
|
|
41
|
+
out("No workspaces.");
|
|
42
|
+
note(" A token acts for its owner, so this is every workspace they belong to.");
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
table(
|
|
47
|
+
workspaces.map((w) => [w.slug ?? "", w.role ?? "", clip(w.name)]),
|
|
48
|
+
{ head: ["SLUG", "ROLE", "NAME"] },
|
|
49
|
+
);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
package/src/events.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { api } from "./client.js";
|
|
2
|
+
import { oneValue, positiveInt } from "./flags.js";
|
|
3
|
+
import { clip, dim, json, out, safeText, table } from "./output.js";
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
* The activity log, for whatever the activity is about.
|
|
7
|
+
*
|
|
8
|
+
* One renderer rather than one per kind, because the endpoint is one pager: a
|
|
9
|
+
* task and a request produce structurally identical events — same kinds, same
|
|
10
|
+
* two sides, same actor — and the server publishes them through the same view.
|
|
11
|
+
*
|
|
12
|
+
* **What this is for.** The comment thread records what people *said*; this
|
|
13
|
+
* records what anyone, person or agent, actually *did*. "What was tried and then
|
|
14
|
+
* reverted" is the context an executor cannot recover any other way, and the
|
|
15
|
+
* usual cost of not having it is re-doing an approach somebody abandoned for a
|
|
16
|
+
* reason nobody wrote down.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {any} ctx
|
|
21
|
+
* @param {string} itemPath the item's own path, as the server published it
|
|
22
|
+
* @param {Record<string, any>} flags
|
|
23
|
+
*/
|
|
24
|
+
export async function listEvents(ctx, itemPath, flags) {
|
|
25
|
+
const res = await api(ctx, "GET", `${itemPath}/events`, {
|
|
26
|
+
query: {
|
|
27
|
+
// Single-valued: the endpoint reads one `?kind=` and one `?field=`, so a
|
|
28
|
+
// second would be dropped rather than widen the filter.
|
|
29
|
+
kind: oneValue(flags, "kind"),
|
|
30
|
+
field: oneValue(flags, "field"),
|
|
31
|
+
limit: positiveInt(flags.limit, "limit"),
|
|
32
|
+
before: oneValue(flags, "before"),
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const events = res?.events ?? [];
|
|
37
|
+
if (flags.json) return json(res), 0;
|
|
38
|
+
if (events.length === 0) return out("No events."), 0;
|
|
39
|
+
|
|
40
|
+
table(
|
|
41
|
+
events.map((e) => [
|
|
42
|
+
when(e.createdAt),
|
|
43
|
+
e.kind ?? "",
|
|
44
|
+
e.field ?? "",
|
|
45
|
+
safeText(e.actor?.name) || "—",
|
|
46
|
+
move(e),
|
|
47
|
+
]),
|
|
48
|
+
{ head: ["WHEN", "KIND", "FIELD", "WHO", "WHAT"] },
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
/*
|
|
52
|
+
* `total` is the history's whole length, NOT the number of rows the filter
|
|
53
|
+
* matched — the endpoint counts every event on the item and pages the filtered
|
|
54
|
+
* set. Printed as "1 of 6" that reads as "five more matches", which is the
|
|
55
|
+
* wrong thing to believe about a filter, so a filtered page says what it is.
|
|
56
|
+
*/
|
|
57
|
+
const more = res?.nextBefore;
|
|
58
|
+
const total = typeof res?.total === "number" ? res.total : null;
|
|
59
|
+
const filtered = Boolean(flags.kind || flags.field);
|
|
60
|
+
const count =
|
|
61
|
+
total === null
|
|
62
|
+
? `${events.length} shown.`
|
|
63
|
+
: filtered
|
|
64
|
+
? `${events.length} shown — the history has ${total} events in all.`
|
|
65
|
+
: `${events.length} of ${total}.`;
|
|
66
|
+
out(dim(`\n${count}${more ? ` Next page: --before ${more}` : ""}`));
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `2026-09-10 14:02`, in the reader's own zone. Seconds are noise here. */
|
|
71
|
+
function when(iso) {
|
|
72
|
+
const d = new Date(iso);
|
|
73
|
+
if (Number.isNaN(d.getTime())) return safeText(iso);
|
|
74
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
75
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `before → after`, short enough for a row.
|
|
80
|
+
*
|
|
81
|
+
* A list response elides long values and says so (`truncated`), so a spec
|
|
82
|
+
* rewrite reads as a length rather than as half a document pasted into a table.
|
|
83
|
+
* The full stored value is one event read away — `zumino api GET …/events/{id}`.
|
|
84
|
+
*/
|
|
85
|
+
function move(e) {
|
|
86
|
+
const side = (v) => {
|
|
87
|
+
if (!v) return "";
|
|
88
|
+
if (v.truncated) return `${clip(v.value, 24)} (${v.length} chars)`;
|
|
89
|
+
const value = v.value;
|
|
90
|
+
if (value === null || value === undefined) return "—";
|
|
91
|
+
if (typeof value === "string") return clip(value, 28);
|
|
92
|
+
// A person, as the log records one: `{id, name}`. Printed as JSON it fills
|
|
93
|
+
// the column with an id nobody reads, and the name is the whole point of
|
|
94
|
+
// the row — `assigneeId: — → Yuna Park`.
|
|
95
|
+
if (value && typeof value === "object" && "id" in value && "name" in value) {
|
|
96
|
+
return clip(value.name ?? value.id, 28);
|
|
97
|
+
}
|
|
98
|
+
return clip(JSON.stringify(value), 28);
|
|
99
|
+
};
|
|
100
|
+
const before = side(e.before);
|
|
101
|
+
const after = side(e.after);
|
|
102
|
+
if (!before && !after) return "";
|
|
103
|
+
return before ? `${before} → ${after}` : after;
|
|
104
|
+
}
|