@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.
@@ -0,0 +1,174 @@
1
+ import { existsSync, lstatSync, writeFileSync } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { createInterface } from "node:readline/promises";
5
+
6
+ import { api } from "../client.js";
7
+ import { readConfig, readRepoFile, repoKeys, resolveContext, writeConfig } from "../config.js";
8
+ import { CliError } from "../errors.js";
9
+ import { bold, dim, note, out, workspaceSlug } from "../output.js";
10
+
11
+ /**
12
+ * `zumino init` — tie this repository to a project.
13
+ *
14
+ * This is the product half of `tasks/every-repo-knows-its-project.md`: a tracker
15
+ * only tells the truth if the work goes through it, and the failure mode is not
16
+ * that people refuse to file tickets — it is that an agent doing an hour of real
17
+ * work in a repo has no reason to know a project exists.
18
+ *
19
+ * It writes two things, in two places, on purpose:
20
+ *
21
+ * - **`.zumino.json`, committed** — the host, workspace and project. Facts
22
+ * about the work, true for everyone who clones, and carrying no secret.
23
+ * - **the repo map in `~/.config/zumino/config.json`** — which *account* this
24
+ * person uses here. A fact about this laptop, kept beside the token.
25
+ *
26
+ * That split is what lets a repository be self-describing without being able to
27
+ * name anybody's credential.
28
+ */
29
+ export async function run(args, flags) {
30
+ const cwd = process.cwd();
31
+ const root = gitRoot(cwd) ?? cwd;
32
+ const target = join(root, ".zumino.json");
33
+ const existing = readRepoFile(root);
34
+
35
+ // A repository can commit `.zumino.json` as a symlink (git mode 120000) to
36
+ // any path, and `writeFileSync` follows one — so `zumino init` in a hostile
37
+ // checkout would truncate whatever it pointed at. `existsSync` follows links
38
+ // too, which made it worse: a link to an existing file produced the "already
39
+ // exists" error whose hint talks the user into `--yes`, and a link to a path
40
+ // that does not exist yet was written through with no confirmation at all.
41
+ //
42
+ // `zumino skill install` already refuses exactly this; the omission here was
43
+ // inconsistent with the package's own guard.
44
+ let link = null;
45
+ try {
46
+ link = lstatSync(target);
47
+ } catch {
48
+ /* absent, which is the ordinary case */
49
+ }
50
+ if (link?.isSymbolicLink()) {
51
+ throw new CliError(`${target} is a symlink.`, {
52
+ hint: "Refusing to write through it — a repository must not choose what this overwrites.",
53
+ });
54
+ }
55
+
56
+ if (existsSync(target) && !flags.yes) {
57
+ throw new CliError(`${target} already exists.`, {
58
+ hint: "Re-run with --yes to overwrite it.",
59
+ });
60
+ }
61
+
62
+ const ctx = resolveContext(flags, cwd);
63
+ const { projects = [] } = (await api(ctx, "GET", "/projects")) ?? {};
64
+ if (projects.length === 0) {
65
+ throw new CliError("You cannot reach any project.", {
66
+ hint: "Create one in the browser first — creating a project is not on the API.",
67
+ });
68
+ }
69
+
70
+ // A project slug is unique per workspace, not globally, so a slug alone can
71
+ // match in two places. `client.js` already refuses that case rather than
72
+ // guessing; picking `[0]` here would write the wrong workspace into a file
73
+ // everyone who clones this repo then inherits.
74
+ // `ctx`, not a second chain. Recomputing from flags and the repo file skipped
75
+ // ZUMINO_PROJECT / ZUMINO_WORKSPACE — rung 2 of the order this branch
76
+ // publishes — while still honouring `.zumino.json` at rung 3, so the
77
+ // environment was resolved and then discarded. `ctx` already folds the flags,
78
+ // the environment, the repo file and the home repo map in the documented
79
+ // order, which is the whole point of resolving once.
80
+ const wanted = ctx.project ?? existing?.project;
81
+ const wantedWorkspace = ctx.workspace ?? existing?.workspace ?? null;
82
+ let chosen = null;
83
+ if (wanted) {
84
+ const hits = projects.filter(
85
+ (p) =>
86
+ (p.slug === wanted || p.key === wanted) &&
87
+ (!wantedWorkspace || workspaceSlug(p.workspace) === wantedWorkspace),
88
+ );
89
+ if (hits.length === 0) {
90
+ throw new CliError(
91
+ `No project "${wanted}"${wantedWorkspace ? ` in workspace "${wantedWorkspace}"` : ""} you can reach.`,
92
+ { hint: "List what you can see with: zumino api GET /projects" },
93
+ );
94
+ }
95
+ if (hits.length > 1) {
96
+ throw new CliError(
97
+ `"${wanted}" exists in more than one workspace: ${hits
98
+ .map((h) => workspaceSlug(h.workspace))
99
+ .join(", ")}.`,
100
+ { hint: "Name one with --workspace <slug>." },
101
+ );
102
+ }
103
+ chosen = hits[0];
104
+ }
105
+ if (!chosen) chosen = await choose(projects);
106
+
107
+ const doc = {
108
+ host: ctx.host,
109
+ workspace: workspaceSlug(chosen.workspace),
110
+ project: chosen.slug,
111
+ };
112
+ writeFileSync(target, JSON.stringify(doc, null, 2) + "\n");
113
+
114
+ // Remember which account answered here, so a later command in this checkout
115
+ // resolves without an environment even if several accounts are configured.
116
+ // `ctx.account`, not a regex over `ctx.source`. Parsing the human-readable
117
+ // source string missed `the only account ("name")` — which is the shape the
118
+ // documented first-run flow produces — so `init` skipped the repo-map write
119
+ // and still exited 0. The repository then looked initialised while being tied
120
+ // to nothing, and every command started refusing the moment a second account
121
+ // was added, which is the whole scenario decision 0006 is built around.
122
+ const accountName = ctx.account;
123
+ if (accountName) {
124
+ const cfg = readConfig();
125
+ for (const key of repoKeys(root).slice(0, 1)) {
126
+ cfg.repos[key] = {
127
+ account: accountName,
128
+ project: chosen.slug,
129
+ workspace: workspaceSlug(chosen.workspace),
130
+ };
131
+ }
132
+ writeConfig(cfg);
133
+ }
134
+
135
+ out(`wrote ${target}`);
136
+ note("");
137
+ note(` This repo files work to ${bold(chosen.slug)} on ${ctx.host}.`);
138
+ if (accountName) note(dim(` Using account "${accountName}" here.`));
139
+ note(dim(" Install the agent skill once, globally: zumino skill install"));
140
+ return 0;
141
+ }
142
+
143
+ function gitRoot(cwd) {
144
+ try {
145
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
146
+ cwd,
147
+ encoding: "utf8",
148
+ stdio: ["ignore", "pipe", "ignore"],
149
+ }).trim();
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ async function choose(projects) {
156
+ if (!process.stdin.isTTY) {
157
+ throw new CliError("Several projects, and nothing said which.", {
158
+ hint: `Pass --project <${projects.slice(0, 4).map((p) => p.slug).join("|")}>`,
159
+ });
160
+ }
161
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
162
+ try {
163
+ projects.forEach((p, i) => note(
164
+ ` ${String(i + 1).padStart(2)}. ${p.slug} ${dim(`${workspaceSlug(p.workspace)} · ${p.type}`)}`,
165
+ ));
166
+ const answer = (await rl.question("\nWhich project? ")).trim();
167
+ const idx = Number(answer) - 1;
168
+ const picked = projects[idx] ?? projects.find((p) => p.slug === answer);
169
+ if (!picked) throw new CliError(`"${answer}" is not one of them.`);
170
+ return picked;
171
+ } finally {
172
+ rl.close();
173
+ }
174
+ }
@@ -0,0 +1,60 @@
1
+ import { api } from "../client.js";
2
+ import { resolveContext } from "../config.js";
3
+ import { clip, codeOf, dim, json, note, out, table, workspaceSlug, positiveInt } from "../output.js";
4
+
5
+ /**
6
+ * `zumino queue` — what to work on next, across every project you can reach.
7
+ *
8
+ * `GET /queue` computes availability rather than reading a column: committed,
9
+ * shaped, and unblocked. That is why an empty answer is a *finding* and this
10
+ * command says so instead of printing nothing — the specs are below the bar or
11
+ * their blockers are open, which is actionable, and silence is not.
12
+ */
13
+ export async function run(args, flags) {
14
+ const ctx = resolveContext(flags);
15
+ const res = await api(ctx, "GET", "/queue", {
16
+ query: {
17
+ needsInput: flags["needs-input"] ? "true" : undefined,
18
+ workspace: ctx.workspace ?? undefined,
19
+ project: ctx.project ?? undefined,
20
+ limit: positiveInt(flags.limit, "limit"),
21
+ },
22
+ });
23
+
24
+ const tasks = res?.tasks ?? [];
25
+ if (flags.json) {
26
+ json(tasks);
27
+ return 0;
28
+ }
29
+
30
+ if (tasks.length === 0) {
31
+ out(
32
+ flags["needs-input"]
33
+ ? "Nothing is waiting on you."
34
+ : "Nothing available to pick up.",
35
+ );
36
+ if (!flags["needs-input"]) {
37
+ note(
38
+ dim(
39
+ " Not the same as no work: the queue offers only tasks that are\n" +
40
+ " committed, shaped and unblocked. What is committed but unshaped\n" +
41
+ " is below the bar — read a candidate with zumino task show <CODE>\n" +
42
+ " and shape it with zumino task spec.",
43
+ ),
44
+ );
45
+ }
46
+ return 0;
47
+ }
48
+
49
+ table(
50
+ tasks.map((t) => [
51
+ codeOf(t),
52
+ t.status ?? "",
53
+ t.priority ?? "",
54
+ clip(t.title),
55
+ dim(`${workspaceSlug(t.workspace)}/${t.project}`),
56
+ ]),
57
+ { head: ["CODE", "STATUS", "PRIORITY", "TITLE", "WHERE"] },
58
+ );
59
+ return 0;
60
+ }
@@ -0,0 +1,176 @@
1
+ import { api } from "../client.js";
2
+ import { resolveContext } from "../config.js";
3
+ import { CliError } from "../errors.js";
4
+ import { clip, json, out, pick, safeText, givenFlag } from "../output.js";
5
+ import { projectPath, resolveItem } from "../items.js";
6
+
7
+ /*
8
+ * Everything that writes a request.
9
+ *
10
+ * **A request on a feedback project has no code**, because a feedback project
11
+ * has no key — `#42` is the whole of its name. So every reference here is
12
+ * resolved as a request by default rather than as a task, and nothing
13
+ * synthesizes a code for one.
14
+ */
15
+
16
+ const SUB = { create, answer, status, note: internalNote, comment, promote, show };
17
+
18
+ export async function run(args, flags) {
19
+ const [sub, ...rest] = args;
20
+ const fn = SUB[sub];
21
+ if (!fn) {
22
+ throw new CliError(`zumino request: unknown subcommand "${sub ?? ""}".`, {
23
+ hint: `One of: ${Object.keys(SUB).join(", ")}`,
24
+ });
25
+ }
26
+ return fn(rest, flags);
27
+ }
28
+
29
+ async function create(args, flags) {
30
+ 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 "…"' });
33
+ const base = await projectPath(ctx);
34
+ const body = { title };
35
+ if (givenFlag(flags.description) !== undefined) body.description = flags.description;
36
+ const res = pick(await api(ctx, "POST", `${base}/requests`, { body }), "request");
37
+ if (flags.json) return json(res), 0;
38
+ out(`#${res.number} ${safeText(res.title)}`);
39
+ return 0;
40
+ }
41
+
42
+ /**
43
+ * Post a comment and mark it the official answer.
44
+ *
45
+ * Two calls because the API models it as two things: at most one comment on a
46
+ * request may be official (`comment.is_official`, held by a partial unique
47
+ * index), so the answer is *a comment that has been designated*, not a field.
48
+ * Doing both here is the whole reason this subcommand exists.
49
+ */
50
+ async function answer(args, flags) {
51
+ const [ref, ...text] = args;
52
+ const bodyText = flags.body ?? text.join(" ").trim();
53
+ if (!ref || !bodyText) throw new CliError("zumino request answer <#N|CODE> <text>");
54
+ const ctx = resolveContext(flags);
55
+ const { path } = await resolveItem(ctx, ref, { kind: "request" });
56
+
57
+ const comment = pick(await api(ctx, "POST", `${path}/comments`, { body: { body: bodyText } }), "comment");
58
+
59
+ // From here the comment is public. The two calls sit behind different gates —
60
+ // commenting is open to any member (and to any token holder on a public
61
+ // feedback project), while marking an answer needs owner/admin — so the second
62
+ // can be refused after the first has landed. It can also be rate limited: the
63
+ // buckets are separate.
64
+ //
65
+ // The failure that matters is not the refusal, it is the silence. Reported as
66
+ // a bare `needAdmin`, nothing says a comment was posted, so the natural
67
+ // response — hand it to an admin, or an agent obeying the skill's "repeat that
68
+ // exact call" rule — posts a second one on a public board, and the API offers
69
+ // no way to delete either.
70
+ try {
71
+ const res = pick(
72
+ await api(ctx, "PUT", `${path}/answer`, { body: { commentId: comment.id } }),
73
+ "request",
74
+ );
75
+ if (flags.json) return json(res), 0;
76
+ out(`${ref} answered`);
77
+ return 0;
78
+ } catch (err) {
79
+ throw new CliError(
80
+ `The comment was posted on ${ref}, but marking it the official answer failed: ${err.message}`,
81
+ {
82
+ exitCode: err.exitCode,
83
+ key: err.key,
84
+ hint:
85
+ `Do NOT re-run this command — it would post a second comment. ` +
86
+ `Mark the existing one: zumino api PUT ${path}/answer --body '{"commentId":"${comment.id}"}'`,
87
+ },
88
+ );
89
+ }
90
+ }
91
+
92
+ async function status(args, flags) {
93
+ const [ref, value] = args;
94
+ if (!ref || !value) {
95
+ throw new CliError("zumino request status <#N|CODE> <status>", {
96
+ hint: "Owner or admin only. Moving to done or wont_do closes the item.",
97
+ });
98
+ }
99
+ const ctx = resolveContext(flags);
100
+ const { path } = await resolveItem(ctx, ref, { kind: "request" });
101
+ const res = pick(await api(ctx, "PATCH", path, { body: { status: value } }), "request");
102
+ if (flags.json) return json(res), 0;
103
+ out(`${ref} ${res.status}`);
104
+ return 0;
105
+ }
106
+
107
+ async function internalNote(args, flags) {
108
+ const [ref, ...text] = args;
109
+ if (!ref) throw new CliError("zumino request note <#N|CODE> <text|->");
110
+ const ctx = resolveContext(flags);
111
+ const { path } = await resolveItem(ctx, ref, { kind: "request" });
112
+ const body = text.join(" ").trim();
113
+ const res = await api(ctx, "PUT", `${path}/internal-note`, {
114
+ body: { note: !body || body === "-" ? null : body },
115
+ });
116
+ if (flags.json) return json(res), 0;
117
+ out(`${ref} note ${body && body !== "-" ? "written" : "cleared"}`);
118
+ return 0;
119
+ }
120
+
121
+ async function comment(args, flags) {
122
+ const [ref, ...text] = args;
123
+ const bodyText = flags.body ?? text.join(" ").trim();
124
+ if (!ref || !bodyText) throw new CliError("zumino request comment <#N|CODE> <text>");
125
+ const ctx = resolveContext(flags);
126
+ const { path } = await resolveItem(ctx, ref, { kind: "request" });
127
+ const res = pick(await api(ctx, "POST", `${path}/comments`, { body: { body: bodyText } }), "comment");
128
+ if (flags.json) return json(res), 0;
129
+ out(`${ref} commented`);
130
+ return 0;
131
+ }
132
+
133
+ /**
134
+ * Promote a request into work.
135
+ *
136
+ * A request never *becomes* a task (`docs/decisions/0002`) — this creates a task
137
+ * that answers it, and both go on living. The wording here says so, because the
138
+ * alternative reading is the one everybody arrives with.
139
+ */
140
+ async function promote(args, flags) {
141
+ const [ref] = args;
142
+ if (!ref) throw new CliError("zumino request promote <#N|CODE> --to <slug>");
143
+
144
+ // The destination is `--to`, not `--project`, because `--project` already
145
+ // names where the *request* lives — and it has to, since a request on a
146
+ // feedback project has no code and `#42` is the whole of its name. Binding
147
+ // one flag to both ends addressed the request in the target project, which
148
+ // either 404s or, when that project happens to have a request of the same
149
+ // number, promotes the wrong item silently.
150
+ const target = flags.to;
151
+ if (!target) {
152
+ throw new CliError("Which work project should the task go in?", {
153
+ hint: "zumino request promote <#N> --to <work-project-slug>",
154
+ });
155
+ }
156
+
157
+ const ctx = resolveContext(flags);
158
+ const { path } = await resolveItem(ctx, ref, { kind: "request" });
159
+ const res = pick(await api(ctx, "POST", `${path}/promote`, { body: { project: target } }), "task");
160
+ if (flags.json) return json(res), 0;
161
+ out(`${ref} → ${res.code ?? res.ref ?? `#${res.number}`} (the request stays open)`);
162
+ return 0;
163
+ }
164
+
165
+ async function show(args, flags) {
166
+ const [ref] = args;
167
+ if (!ref) throw new CliError("zumino request show <#N|CODE>");
168
+ const ctx = resolveContext(flags);
169
+ 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)}`);
175
+ return 0;
176
+ }
@@ -0,0 +1,40 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import { VERSION } from "../client.js";
4
+ import { CliError } from "../errors.js";
5
+ import { dim, note, out } from "../output.js";
6
+
7
+ /**
8
+ * `zumino self-update` — the command every stale-version message names.
9
+ *
10
+ * It exists so that an agent never has to work out which package manager
11
+ * installed this, or whether the install was global. One command, always
12
+ * correct, printed verbatim in the failure that requires it — that is the entire
13
+ * mechanism by which drift gets fixed rather than reported.
14
+ */
15
+ export async function run() {
16
+ const pkg = "@zumino/cli@latest";
17
+ out(`Updating ${pkg} (from ${VERSION})…`);
18
+
19
+ const res = spawnSync("npm", ["install", "-g", pkg], {
20
+ stdio: ["ignore", "inherit", "inherit"],
21
+ encoding: "utf8",
22
+ });
23
+
24
+ if (res.error) {
25
+ throw new CliError(`Could not run npm: ${res.error.message}`, {
26
+ hint: `Update by hand: npm install -g ${pkg}`,
27
+ });
28
+ }
29
+ if (res.status !== 0) {
30
+ throw new CliError(`npm exited ${res.status}.`, {
31
+ // The overwhelmingly common cause, and the one whose fix is not obvious
32
+ // from npm's own error.
33
+ hint: `If this is a permissions failure, either use a node version manager or: sudo npm install -g ${pkg}`,
34
+ });
35
+ }
36
+
37
+ note(dim("\nUpdated. Re-run what you were doing."));
38
+ note(dim("If the skill is older than the CLI: zumino skill install"));
39
+ return 0;
40
+ }
@@ -0,0 +1,97 @@
1
+ import { copyFileSync, lstatSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { VERSION } from "../client.js";
7
+ import { CliError } from "../errors.js";
8
+ import { bold, dim, note, out } from "../output.js";
9
+
10
+ /*
11
+ * `zumino skill install` — put the agent skill where every repo can see it.
12
+ *
13
+ * **Globally, at `~/.claude/skills/zumino/`, and never inside a project.** A
14
+ * per-repo copy is N copies of one document, each aging separately with nothing
15
+ * to say which is current — the same drift the version handshake exists to
16
+ * remove, reintroduced one directory at a time.
17
+ *
18
+ * The skill travels inside this package rather than being fetched, so it is
19
+ * locked to the CLI's version, which is the release version. `--check` compares
20
+ * the two without writing.
21
+ */
22
+
23
+ const here = dirname(fileURLToPath(import.meta.url));
24
+ const BUNDLED = join(here, "..", "..", "skill", "SKILL.md");
25
+
26
+ function defaultDir() {
27
+ return join(homedir(), ".claude", "skills", "zumino");
28
+ }
29
+
30
+ export async function run(args, flags) {
31
+ const [sub = "install"] = args;
32
+ if (sub !== "install") {
33
+ throw new CliError(`zumino skill: unknown subcommand "${sub}".`, { hint: "Only: install" });
34
+ }
35
+
36
+ const dir = flags.dir ? String(flags.dir) : defaultDir();
37
+ const target = join(dir, "SKILL.md");
38
+ const installed = readVersion(target);
39
+
40
+ if (flags.check) {
41
+ if (!installed) {
42
+ out(`not installed (would write ${target})`);
43
+ return 1;
44
+ }
45
+ if (installed === VERSION) {
46
+ out(`skill is ${installed} — current`);
47
+ return 0;
48
+ }
49
+ out(`skill is ${installed}, CLI is ${VERSION} — run without --check to update`);
50
+ return 1;
51
+ }
52
+
53
+ // `lstatSync` in a try, not `existsSync && lstatSync` — `existsSync` follows
54
+ // the link, so a *dangling* one reported absent and both guards were skipped:
55
+ // `mkdirSync` created the real parent and `copyFileSync` wrote through to the
56
+ // link's destination, putting a stray file in a repository the user never
57
+ // asked to touch while printing the link's path as though that were written.
58
+ // `init.js` already uses this shape; the claim that this file did was wrong.
59
+ if (isSymlink(dir)) {
60
+ throw new CliError(`${dir} is a symlink.`, {
61
+ hint: `It probably points into a repo you version. Install there deliberately: zumino skill install --dir <path>`,
62
+ });
63
+ }
64
+ if (isSymlink(target)) {
65
+ throw new CliError(`${target} is a symlink.`, {
66
+ hint: "Refusing to write through it. Use --dir to name a real directory.",
67
+ });
68
+ }
69
+
70
+ mkdirSync(dir, { recursive: true });
71
+ copyFileSync(BUNDLED, target);
72
+
73
+ out(`wrote ${target} (${VERSION})`);
74
+ note("");
75
+ note(` ${bold("One skill, every repo.")} Tie a repo to a project with: zumino init`);
76
+ note(dim(" Check it later with: zumino skill install --check"));
77
+ return 0;
78
+ }
79
+
80
+ /** True for a symlink, dangling or not. */
81
+ function isSymlink(path) {
82
+ try {
83
+ return lstatSync(path).isSymbolicLink();
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /** The `version:` line the bundled skill carries in its frontmatter. */
90
+ function readVersion(path) {
91
+ try {
92
+ const head = readFileSync(path, "utf8").slice(0, 600);
93
+ return /^version:\s*(\S+)\s*$/m.exec(head)?.[1] ?? "unknown";
94
+ } catch {
95
+ return null;
96
+ }
97
+ }