@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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { api, resolveWorkspace } from "../client.js";
|
|
2
|
+
import { requireProject, requireSafeSegment, resolveContext } from "../config.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { clip, dim, json, note, out, safeText, table, workspaceSlug } from "../output.js";
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
* The projects a token can reach, and what is in one.
|
|
8
|
+
*
|
|
9
|
+
* Reads only. Creating, configuring or deleting a project is not on this API
|
|
10
|
+
* surface and will not be: a project takes a permanent, workspace-unique key,
|
|
11
|
+
* and an agent inventing `ONS` because it seemed reasonable is a name you live
|
|
12
|
+
* with (`DOMAIN.md` § Rules, governance).
|
|
13
|
+
*
|
|
14
|
+
* `list` is the read that answers which half of the CLI applies. A **feedback**
|
|
15
|
+
* project has no key and holds requests, so its items are `#42` and
|
|
16
|
+
* `zumino request …` writes them; a **work** project has a key and holds tasks
|
|
17
|
+
* and epics, so its items are `ACME-14` and `zumino task …` does. Getting that
|
|
18
|
+
* wrong is the most common first mistake, and it used to take two `zumino api`
|
|
19
|
+
* calls to avoid.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export const SUB = { list, show, tags };
|
|
23
|
+
|
|
24
|
+
export async function run(args, flags) {
|
|
25
|
+
const [sub, ...rest] = args;
|
|
26
|
+
const fn = SUB[sub];
|
|
27
|
+
if (!fn) {
|
|
28
|
+
throw new CliError(`zumino project: unknown subcommand "${sub ?? ""}".`, {
|
|
29
|
+
hint: `One of: ${Object.keys(SUB).join(", ")}`,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
return fn(rest, flags);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function list(args, flags) {
|
|
36
|
+
const ctx = resolveContext(flags);
|
|
37
|
+
const named = workspaceSlug(ctx.workspace);
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
* Two endpoints answer this, and which one applies is not a detail.
|
|
41
|
+
*
|
|
42
|
+
* `GET /projects` is one of the two deliberately cross-workspace reads and
|
|
43
|
+
* lists every project in every workspace the token's owner *belongs to*. The
|
|
44
|
+
* workspace-scoped read answers a different question: a workspace slug is not
|
|
45
|
+
* a secret, so a non-member may name one and see its public projects. Without
|
|
46
|
+
* the second call, `--workspace` on a public board you do not belong to
|
|
47
|
+
* answered an empty list — correct for the endpoint, wrong for the question.
|
|
48
|
+
*/
|
|
49
|
+
const res = named
|
|
50
|
+
? await api(ctx, "GET", `/workspaces/${named}/projects`)
|
|
51
|
+
: await api(ctx, "GET", "/projects");
|
|
52
|
+
const projects = res?.projects ?? [];
|
|
53
|
+
if (flags.json) return json(projects), 0;
|
|
54
|
+
|
|
55
|
+
if (projects.length === 0) {
|
|
56
|
+
out("No projects.");
|
|
57
|
+
note(
|
|
58
|
+
dim(
|
|
59
|
+
" Either this token's owner belongs to no workspace with projects, or\n" +
|
|
60
|
+
" --workspace named one they cannot see. Check with: zumino workspace list",
|
|
61
|
+
),
|
|
62
|
+
);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
table(
|
|
67
|
+
projects.map((p) => [
|
|
68
|
+
`${workspaceSlug(p.workspace) ?? "?"}/${p.slug}`,
|
|
69
|
+
p.type ?? "",
|
|
70
|
+
p.key ?? "—",
|
|
71
|
+
String(p.type === "feedback" ? (p.requestCount ?? 0) : (p.taskCount ?? 0)),
|
|
72
|
+
clip(p.name, 40),
|
|
73
|
+
]),
|
|
74
|
+
{ head: ["PROJECT", "TYPE", "KEY", "ITEMS", "NAME"] },
|
|
75
|
+
);
|
|
76
|
+
out(
|
|
77
|
+
dim(
|
|
78
|
+
"\nITEMS is requests on a feedback project and tasks on a work project.\n" +
|
|
79
|
+
"A work project's KEY is what an item's code is written with.",
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The project named as an argument, or the one the context resolved. */
|
|
86
|
+
async function target(ctx, args) {
|
|
87
|
+
const named = args[0]
|
|
88
|
+
? requireSafeSegment(args[0], "project", "the project argument")
|
|
89
|
+
: requireProject(ctx);
|
|
90
|
+
const workspace = await resolveWorkspace({ ...ctx, project: named });
|
|
91
|
+
return { project: named, workspace };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function show(args, flags) {
|
|
95
|
+
const ctx = resolveContext(flags);
|
|
96
|
+
const { project, workspace } = await target(ctx, args);
|
|
97
|
+
const res = await api(ctx, "GET", `/workspaces/${workspace}/projects/${project}`);
|
|
98
|
+
if (flags.json) return json(res), 0;
|
|
99
|
+
|
|
100
|
+
const p = res?.project ?? {};
|
|
101
|
+
const stats = res?.stats ?? {};
|
|
102
|
+
out(`${workspace}/${p.slug} ${safeText(p.name)}`);
|
|
103
|
+
out(`type ${p.type ?? "?"} key ${p.key ?? "—"} visibility ${p.visibility ?? "?"}`);
|
|
104
|
+
if (p.description) out(`\n${safeText(p.description)}`);
|
|
105
|
+
|
|
106
|
+
// The counters are the feedback half's summary and answer with zeroes on a
|
|
107
|
+
// work project — so they are printed where they mean something and the other
|
|
108
|
+
// half is pointed at the read that does answer.
|
|
109
|
+
if (p.type === "feedback") {
|
|
110
|
+
out(
|
|
111
|
+
`\nrequests ${stats.total ?? 0} votes ${stats.votes ?? 0} comments ${stats.comments ?? 0}`,
|
|
112
|
+
);
|
|
113
|
+
if (stats.byStatus) {
|
|
114
|
+
out(
|
|
115
|
+
dim(
|
|
116
|
+
Object.entries(stats.byStatus)
|
|
117
|
+
.map(([k, v]) => `${k} ${v}`)
|
|
118
|
+
.join(" "),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
out(dim(`\nCount the work with: zumino task list --project ${p.slug} --json | jq length`));
|
|
124
|
+
}
|
|
125
|
+
return 0;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function tags(args, flags) {
|
|
129
|
+
const ctx = resolveContext(flags);
|
|
130
|
+
const { project, workspace } = await target(ctx, args);
|
|
131
|
+
const res = await api(ctx, "GET", `/workspaces/${workspace}/projects/${project}/tags`);
|
|
132
|
+
const list = res?.tags ?? [];
|
|
133
|
+
if (flags.json) return json(list), 0;
|
|
134
|
+
|
|
135
|
+
if (list.length === 0) {
|
|
136
|
+
out("No tags.");
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
table(
|
|
140
|
+
list.map((t) => [t.id ?? "", clip(t.name, 30), t.color ?? ""]),
|
|
141
|
+
{ head: ["ID", "NAME", "COLOUR"] },
|
|
142
|
+
);
|
|
143
|
+
// The id is the filter's argument, and it is the only reason this read exists
|
|
144
|
+
// — `--tag` takes an id because a name is not unique across projects.
|
|
145
|
+
out(dim("\nFilter with: zumino task list --tag <ID>"));
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
package/src/commands/queue.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { api } from "../client.js";
|
|
2
2
|
import { resolveContext } from "../config.js";
|
|
3
|
-
import { clip, codeOf, dim, json, note, out, table, workspaceSlug
|
|
3
|
+
import { clip, codeOf, dim, json, note, out, table, workspaceSlug } from "../output.js";
|
|
4
|
+
import { positiveInt } from "../flags.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* `zumino queue` — what to work on next, across every project you can reach.
|
package/src/commands/request.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { api } from "../client.js";
|
|
2
2
|
import { resolveContext } from "../config.js";
|
|
3
3
|
import { CliError } from "../errors.js";
|
|
4
|
-
import { clip, json, out, pick, safeText,
|
|
4
|
+
import { clip, dim, json, out, pageLine, pick, safeText, table } from "../output.js";
|
|
5
|
+
import {
|
|
6
|
+
csv,
|
|
7
|
+
givenFlag,
|
|
8
|
+
nonNegativeInt,
|
|
9
|
+
oneValue,
|
|
10
|
+
positiveInt,
|
|
11
|
+
searchText,
|
|
12
|
+
} from "../flags.js";
|
|
13
|
+
import { listEvents } from "../events.js";
|
|
5
14
|
import { projectPath, resolveItem } from "../items.js";
|
|
6
15
|
|
|
7
16
|
/*
|
|
8
|
-
*
|
|
17
|
+
* Requests: what people asked for, read and written.
|
|
9
18
|
*
|
|
10
19
|
* **A request on a feedback project has no code**, because a feedback project
|
|
11
20
|
* has no key — `#42` is the whole of its name. So every reference here is
|
|
@@ -13,7 +22,20 @@ import { projectPath, resolveItem } from "../items.js";
|
|
|
13
22
|
* synthesizes a code for one.
|
|
14
23
|
*/
|
|
15
24
|
|
|
16
|
-
const SUB = {
|
|
25
|
+
export const SUB = {
|
|
26
|
+
list,
|
|
27
|
+
show,
|
|
28
|
+
events,
|
|
29
|
+
create,
|
|
30
|
+
edit,
|
|
31
|
+
answer,
|
|
32
|
+
status,
|
|
33
|
+
note: internalNote,
|
|
34
|
+
comment,
|
|
35
|
+
promote,
|
|
36
|
+
tag,
|
|
37
|
+
untag,
|
|
38
|
+
};
|
|
17
39
|
|
|
18
40
|
export async function run(args, flags) {
|
|
19
41
|
const [sub, ...rest] = args;
|
|
@@ -26,19 +48,117 @@ export async function run(args, flags) {
|
|
|
26
48
|
return fn(rest, flags);
|
|
27
49
|
}
|
|
28
50
|
|
|
51
|
+
async function list(args, flags) {
|
|
52
|
+
const ctx = resolveContext(flags);
|
|
53
|
+
const base = await projectPath(ctx);
|
|
54
|
+
const offset = nonNegativeInt(flags.offset, "offset");
|
|
55
|
+
|
|
56
|
+
const res = await api(ctx, "GET", `${base}/requests`, {
|
|
57
|
+
query: {
|
|
58
|
+
q: searchText(args, flags),
|
|
59
|
+
status: csv(flags, "status"),
|
|
60
|
+
type: oneValue(flags, "type"),
|
|
61
|
+
tag: oneValue(flags, "tag"),
|
|
62
|
+
sort: oneValue(flags, "sort"),
|
|
63
|
+
offset,
|
|
64
|
+
limit: positiveInt(flags.limit, "limit"),
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
const requests = res?.requests ?? [];
|
|
69
|
+
if (flags.json) return json(requests), 0;
|
|
70
|
+
if (requests.length === 0) return out("No requests match."), 0;
|
|
71
|
+
|
|
72
|
+
table(
|
|
73
|
+
requests.map((r) => [
|
|
74
|
+
`#${r.number}`,
|
|
75
|
+
r.status ?? "",
|
|
76
|
+
String(r.voteCount ?? 0),
|
|
77
|
+
r.type ?? "",
|
|
78
|
+
clip(r.title, 52),
|
|
79
|
+
]),
|
|
80
|
+
{ head: ["ID", "STATUS", "VOTES", "TYPE", "TITLE"] },
|
|
81
|
+
);
|
|
82
|
+
out(dim(`\n${pageLine(res, requests.length, offset)}`));
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function show(args, flags) {
|
|
87
|
+
const [ref] = args;
|
|
88
|
+
if (!ref) throw new CliError("zumino request show <#N|CODE>");
|
|
89
|
+
const ctx = resolveContext(flags);
|
|
90
|
+
const { path } = await resolveItem(ctx, ref, { kind: "request" });
|
|
91
|
+
const res = pick(await api(ctx, "GET", path), "request");
|
|
92
|
+
if (flags.json) return json(res), 0;
|
|
93
|
+
out(`#${res.number} ${safeText(res.title)}`);
|
|
94
|
+
out(`status ${res.status} votes ${res.voteCount ?? 0}`);
|
|
95
|
+
if (res.description) out(`\n${clip(res.description, 2000)}`);
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function events(args, flags) {
|
|
100
|
+
const [ref] = args;
|
|
101
|
+
if (!ref) throw new CliError("zumino request events <#N|CODE>");
|
|
102
|
+
const ctx = resolveContext(flags);
|
|
103
|
+
const { path } = await resolveItem(ctx, ref, { kind: "request" });
|
|
104
|
+
return listEvents(ctx, path, flags);
|
|
105
|
+
}
|
|
106
|
+
|
|
29
107
|
async function create(args, flags) {
|
|
30
108
|
const ctx = resolveContext(flags);
|
|
31
|
-
const title = flags
|
|
32
|
-
if (!title) throw new CliError(
|
|
109
|
+
const title = oneValue(flags, "title") ?? args.join(" ").trim();
|
|
110
|
+
if (!title) throw new CliError("A request needs a title.", { hint: 'zumino request create --title "…"' });
|
|
33
111
|
const base = await projectPath(ctx);
|
|
34
112
|
const body = { title };
|
|
35
|
-
if (givenFlag(flags.description) !== undefined) body.description = flags.description;
|
|
113
|
+
if (givenFlag(flags.description) !== undefined) body.description = givenFlag(flags.description);
|
|
114
|
+
const type = oneValue(flags, "type");
|
|
115
|
+
if (type !== undefined) body.type = type;
|
|
36
116
|
const res = pick(await api(ctx, "POST", `${base}/requests`, { body }), "request");
|
|
37
117
|
if (flags.json) return json(res), 0;
|
|
38
118
|
out(`#${res.number} ${safeText(res.title)}`);
|
|
39
119
|
return 0;
|
|
40
120
|
}
|
|
41
121
|
|
|
122
|
+
/**
|
|
123
|
+
* One PATCH carrying every field that was named.
|
|
124
|
+
*
|
|
125
|
+
* The two halves of this body sit behind different gates — the words are the
|
|
126
|
+
* author's or a moderator's, the status and the pinning are a moderator's — so a
|
|
127
|
+
* mixed call can be refused as a whole. That is the server's rule, and the reason
|
|
128
|
+
* `status` also keeps its own subcommand: deciding a request is a distinct act,
|
|
129
|
+
* and it reads better as one.
|
|
130
|
+
*/
|
|
131
|
+
async function edit(args, flags) {
|
|
132
|
+
const [ref] = args;
|
|
133
|
+
if (!ref) throw new CliError("zumino request edit <#N|CODE> [fields]");
|
|
134
|
+
const ctx = resolveContext(flags);
|
|
135
|
+
|
|
136
|
+
/** @type {Record<string, any>} */
|
|
137
|
+
const body = {};
|
|
138
|
+
for (const name of ["title", "description"]) {
|
|
139
|
+
const v = givenFlag(flags[name]);
|
|
140
|
+
if (v !== undefined) body[name] = v;
|
|
141
|
+
}
|
|
142
|
+
for (const name of ["type", "status"]) {
|
|
143
|
+
const v = oneValue(flags, name);
|
|
144
|
+
if (v !== undefined) body[name] = v;
|
|
145
|
+
}
|
|
146
|
+
const pinned = oneValue(flags, "pinned");
|
|
147
|
+
if (pinned !== undefined) body.pinned = pinned === "true";
|
|
148
|
+
|
|
149
|
+
if (Object.keys(body).length === 0) {
|
|
150
|
+
throw new CliError("Nothing to change.", {
|
|
151
|
+
hint: "Name at least one field. See: zumino help request edit",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const { path } = await resolveItem(ctx, ref, { kind: "request" });
|
|
156
|
+
const res = pick(await api(ctx, "PATCH", path, { body }), "request");
|
|
157
|
+
if (flags.json) return json(res), 0;
|
|
158
|
+
out(`#${res.number} ${Object.keys(body).join(", ")} written`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
42
162
|
/**
|
|
43
163
|
* Post a comment and mark it the official answer.
|
|
44
164
|
*
|
|
@@ -147,7 +267,7 @@ async function promote(args, flags) {
|
|
|
147
267
|
// one flag to both ends addressed the request in the target project, which
|
|
148
268
|
// either 404s or, when that project happens to have a request of the same
|
|
149
269
|
// number, promotes the wrong item silently.
|
|
150
|
-
const target = flags
|
|
270
|
+
const target = oneValue(flags, "to");
|
|
151
271
|
if (!target) {
|
|
152
272
|
throw new CliError("Which work project should the task go in?", {
|
|
153
273
|
hint: "zumino request promote <#N> --to <work-project-slug>",
|
|
@@ -162,15 +282,33 @@ async function promote(args, flags) {
|
|
|
162
282
|
return 0;
|
|
163
283
|
}
|
|
164
284
|
|
|
165
|
-
async function
|
|
166
|
-
|
|
167
|
-
|
|
285
|
+
async function tag(args, flags) {
|
|
286
|
+
return setTag(args, flags, true);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function untag(args, flags) {
|
|
290
|
+
return setTag(args, flags, false);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** A tag on or off, by id — `zumino project tags` lists them. */
|
|
294
|
+
async function setTag(args, flags, on) {
|
|
295
|
+
const [ref, tagId] = args;
|
|
296
|
+
const verb = on ? "tag" : "untag";
|
|
297
|
+
if (!ref || !tagId) {
|
|
298
|
+
throw new CliError(`zumino request ${verb} <#N|CODE> <tagId>`, {
|
|
299
|
+
hint: "List the project's tag ids with: zumino project tags",
|
|
300
|
+
});
|
|
301
|
+
}
|
|
168
302
|
const ctx = resolveContext(flags);
|
|
169
303
|
const { path } = await resolveItem(ctx, ref, { kind: "request" });
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
304
|
+
// Written as two calls with literal methods rather than one with a ternary:
|
|
305
|
+
// `test/cli-routes.test.ts` reads these call sites out of the source to check
|
|
306
|
+
// each against the server, and a method it cannot read is an endpoint nothing
|
|
307
|
+
// checks.
|
|
308
|
+
const res = on
|
|
309
|
+
? await api(ctx, "PUT", `${path}/tags/${encodeURIComponent(tagId)}`)
|
|
310
|
+
: await api(ctx, "DELETE", `${path}/tags/${encodeURIComponent(tagId)}`);
|
|
311
|
+
if (flags.json) return json(res ?? { ok: true }), 0;
|
|
312
|
+
out(`${ref} ${on ? "tagged" : "untagged"}`);
|
|
175
313
|
return 0;
|
|
176
314
|
}
|
package/src/commands/skill.js
CHANGED
|
@@ -27,12 +27,22 @@ function defaultDir() {
|
|
|
27
27
|
return join(homedir(), ".claude", "skills", "zumino");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export const SUB = { install };
|
|
31
|
+
|
|
30
32
|
export async function run(args, flags) {
|
|
33
|
+
// `zumino skill` has meant `zumino skill install` since the first release, and
|
|
34
|
+
// it is the one group with a default: there is nothing else to do with a
|
|
35
|
+
// bundled document. `spec.js` records that as `defaultSub`, so the dispatcher
|
|
36
|
+
// validates the flags against `install` rather than refusing a bare `skill`.
|
|
31
37
|
const [sub = "install"] = args;
|
|
32
|
-
|
|
38
|
+
const fn = SUB[sub];
|
|
39
|
+
if (!fn) {
|
|
33
40
|
throw new CliError(`zumino skill: unknown subcommand "${sub}".`, { hint: "Only: install" });
|
|
34
41
|
}
|
|
42
|
+
return fn(args.slice(1), flags);
|
|
43
|
+
}
|
|
35
44
|
|
|
45
|
+
async function install(args, flags) {
|
|
36
46
|
const dir = flags.dir ? String(flags.dir) : defaultDir();
|
|
37
47
|
const target = join(dir, "SKILL.md");
|
|
38
48
|
const installed = readVersion(target);
|