@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.
@@ -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
+ }
@@ -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, positiveInt } from "../output.js";
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.
@@ -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, givenFlag } from "../output.js";
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
- * Everything that writes a request.
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 = { create, answer, status, note: internalNote, comment, promote, show };
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.title ?? args.join(" ").trim();
32
- if (!title) throw new CliError('A request needs a title.', { hint: 'zumino request create --title "…"' });
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.to;
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 show(args, flags) {
166
- const [ref] = args;
167
- if (!ref) throw new CliError("zumino request show <#N|CODE>");
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
- const res = pick(await api(ctx, "GET", path), "request");
171
- if (flags.json) return json(res), 0;
172
- out(`#${res.number} ${safeText(res.title)}`);
173
- out(`status ${res.status} votes ${res.voteCount ?? 0}`);
174
- if (res.description) out(`\n${clip(res.description, 2000)}`);
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
  }
@@ -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
- if (sub !== "install") {
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);