@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/src/main.js CHANGED
@@ -2,7 +2,10 @@ import { parseArgs } from "node:util";
2
2
 
3
3
  import { CliError, EXIT_FAILURE } from "./errors.js";
4
4
  import { VERSION } from "./client.js";
5
- import { bold, dim, note, out } from "./output.js";
5
+ import { note, out } from "./output.js";
6
+ import { command, parseOptions, subcommand } from "./spec.js";
7
+ import { overview, page, suggest } from "./help.js";
8
+ import { validateFlags } from "./flags.js";
6
9
 
7
10
  import * as auth from "./commands/auth.js";
8
11
  import * as queue from "./commands/queue.js";
@@ -11,54 +14,30 @@ import * as find from "./commands/find.js";
11
14
  import * as task from "./commands/task.js";
12
15
  import * as request from "./commands/request.js";
13
16
  import * as epic from "./commands/epic.js";
17
+ import * as project from "./commands/project.js";
18
+ import * as workspace from "./commands/workspace.js";
14
19
  import * as raw from "./commands/api.js";
15
20
  import * as init from "./commands/init.js";
16
21
  import * as skill from "./commands/skill.js";
17
22
  import * as selfUpdate from "./commands/self-update.js";
23
+ import * as commands from "./commands/commands.js";
18
24
 
19
25
  /*
20
26
  * Argument parsing and dispatch.
21
27
  *
22
- * The command shape is `DOMAIN.md`'s rule, not a style choice: **generic reads
23
- * span every kind, and every write names its kind.** `queue`, `context` and
24
- * `find` are top-level because they answer across kinds; everything that writes
25
- * is `task …`, `request …` or `epic …`, so a fourth kind adds a noun rather
26
- * than widening a flat verb list every script has already learned.
28
+ * **What a command is, and what flags it takes, is declared in `spec.js` and
29
+ * nowhere else.** This file looks the command up there, refuses a flag it does
30
+ * not accept, and hands the rest to the module below. The help pages are
31
+ * rendered from the same declaration, so there is no usage string here to fall
32
+ * out of step with what actually runs.
33
+ *
34
+ * The shape of the surface is `DOMAIN.md`'s rule rather than a style choice:
35
+ * **generic reads span every kind, and every write names its kind.** `queue`,
36
+ * `context` and `find` are top-level because they answer across kinds;
37
+ * everything that touches one kind hangs off that kind's noun, so a fourth kind
38
+ * adds a noun rather than widening a flat verb list every script has learned.
27
39
  */
28
40
 
29
- const OPTIONS = {
30
- // global
31
- json: { type: "boolean" },
32
- host: { type: "string" },
33
- token: { type: "string" },
34
- project: { type: "string" },
35
- workspace: { type: "string" },
36
- account: { type: "string" },
37
- help: { type: "boolean", short: "h" },
38
- version: { type: "boolean" },
39
- limit: { type: "string" },
40
- // reads
41
- "needs-input": { type: "boolean" },
42
- kind: { type: "string" },
43
- status: { type: "string" },
44
- state: { type: "string" },
45
- activity: { type: "boolean" },
46
- // writes
47
- title: { type: "string" },
48
- description: { type: "string" },
49
- assignee: { type: "string" },
50
- epic: { type: "string" },
51
- plan: { type: "string" },
52
- acceptance: { type: "string" },
53
- url: { type: "string" },
54
- body: { type: "string" },
55
- to: { type: "string" },
56
- // housekeeping
57
- dir: { type: "string" },
58
- check: { type: "boolean" },
59
- yes: { type: "boolean", short: "y" },
60
- };
61
-
62
41
  const COMMANDS = {
63
42
  queue: queue.run,
64
43
  context: context.run,
@@ -66,58 +45,23 @@ const COMMANDS = {
66
45
  task: task.run,
67
46
  request: request.run,
68
47
  epic: epic.run,
48
+ project: project.run,
49
+ workspace: workspace.run,
69
50
  api: raw.run,
70
51
  auth: auth.run,
71
52
  init: init.run,
72
53
  skill: skill.run,
73
54
  "self-update": selfUpdate.run,
55
+ commands: commands.run,
74
56
  };
75
57
 
76
- const USAGE = `${bold("zumino")} — drive Zumino from a terminal, a script, or an agent.
77
-
78
- ${bold("Reads")} (across every kind of item)
79
- zumino queue [--needs-input] [--limit N] what to work on next
80
- zumino context <CODE> [--activity] the whole brief for one task
81
- zumino find <query> [--kind request|task|epic] [--state open|closed]
82
-
83
- ${bold("Writes")} (each names its kind)
84
- zumino task create --title T [--description D] [--epic N]
85
- zumino task status <CODE> <status>
86
- zumino task assign <CODE> <userId|->
87
- zumino task spec <CODE> --plan TEXT | --acceptance TEXT
88
- zumino task comment <CODE> <text>
89
- zumino task link <CODE> <blocks|blocked-by|related|answers> <CODE>
90
- zumino task ref <CODE> --url URL [--title T]
91
- zumino request answer <CODE|#N> <text>
92
- zumino request promote <CODE|#N> --to <work-project>
93
- zumino request status <CODE|#N> <status>
94
- zumino request note <CODE|#N> <text>
95
- zumino request comment <CODE|#N> <text>
96
- zumino epic create --title T
97
- zumino epic status <CODE> <status>
98
-
99
- ${bold("Everything else")}
100
- zumino api <METHOD> <PATH> [--body JSON] any endpoint, including new ones
101
- zumino auth login | status | list | logout
102
- zumino init write .zumino.json for this repo
103
- zumino skill install [--dir D] [--check] install the agent skill, globally
104
- zumino self-update
105
-
106
- ${bold("Context")} is resolved in this order, and ${dim("zumino auth status")} says which won:
107
- --token/--account → ZUMINO_TOKEN → .zumino.json → repo map → sole account
108
-
109
- ${bold("Flags")} --json --project P --workspace W --host URL --account NAME
110
- ${bold("Env")} ZUMINO_TOKEN ZUMINO_URL ZUMINO_PROJECT ZUMINO_WORKSPACE
111
- ZUMINO_ACCOUNT ZUMINO_NO_UPDATE_CHECK
112
- ${bold("Exit")} 0 ok · 1 failed · 2 nothing resolved · 3 CLI too old`;
113
-
114
58
  /** @param {string[]} argv */
115
59
  export async function main(argv) {
116
60
  let parsed;
117
61
  try {
118
62
  parsed = parseArgs({
119
63
  args: argv,
120
- options: OPTIONS,
64
+ options: parseOptions(),
121
65
  allowPositionals: true,
122
66
  strict: true,
123
67
  });
@@ -133,21 +77,57 @@ export async function main(argv) {
133
77
  out(VERSION);
134
78
  return 0;
135
79
  }
80
+
136
81
  const [name, ...rest] = positionals;
137
- if (!name || name === "help" || (flags.help && !name)) {
138
- out(USAGE);
82
+
83
+ // `zumino`, `zumino --help`, `zumino help`, `zumino help task list`.
84
+ if (!name || name === "help") {
85
+ const words = name === "help" ? rest : [];
86
+ const rendered = page(words);
87
+ if (rendered === null) return notACommand(words.join(" "));
88
+ out(rendered);
139
89
  return 0;
140
90
  }
141
91
 
142
- const run = COMMANDS[name];
143
- if (!run) {
144
- note(`zumino: unknown command "${name}".`);
145
- note("Try: zumino --help");
146
- return EXIT_FAILURE;
92
+ const top = command(name);
93
+ if (!top) return notACommand(name);
94
+
95
+ // `zumino task --help` and `zumino task list --help` are the same pages as
96
+ // `zumino help …`, because an agent reaches for whichever it learned first.
97
+ if (flags.help) {
98
+ out(page([name, rest[0]]) ?? page([name]) ?? overview());
99
+ return 0;
100
+ }
101
+
102
+ /*
103
+ * A group needs a subcommand, and saying which ones exist is more useful than
104
+ * repeating that one is missing. The page goes to stdout because it is the
105
+ * answer to "what can this do"; the refusal goes to stderr and the exit code
106
+ * is a failure, because the command as typed did nothing.
107
+ */
108
+ let leaf = top;
109
+ let path = top.name;
110
+ if (top.subcommands) {
111
+ const sub = rest[0] ?? top.defaultSub;
112
+ const found = sub ? subcommand(top, sub) : null;
113
+ if (!found) {
114
+ note(
115
+ sub
116
+ ? `zumino: ${top.name} has no subcommand "${sub}".`
117
+ : `zumino: ${top.name} needs a subcommand.`,
118
+ );
119
+ out(page([top.name]));
120
+ return EXIT_FAILURE;
121
+ }
122
+ leaf = found;
123
+ path = `${top.name} ${sub}`;
147
124
  }
148
125
 
149
126
  try {
150
- return (await run(rest, flags)) ?? 0;
127
+ // Before the command runs, not inside it: a flag this command does not take
128
+ // is a refusal, never a value that reaches the wire and is ignored there.
129
+ validateFlags(leaf, flags, path);
130
+ return (await COMMANDS[name](rest, flags)) ?? 0;
151
131
  } catch (err) {
152
132
  if (err instanceof CliError) {
153
133
  note(`zumino: ${err.message}`);
@@ -161,3 +141,10 @@ export async function main(argv) {
161
141
  return EXIT_FAILURE;
162
142
  }
163
143
  }
144
+
145
+ function notACommand(name) {
146
+ note(`zumino: unknown command "${name}".`);
147
+ const guess = suggest(name);
148
+ note(guess ? ` Did you mean: ${guess}` : " Try: zumino --help");
149
+ return EXIT_FAILURE;
150
+ }
package/src/output.js CHANGED
@@ -1,4 +1,3 @@
1
- import { CliError } from "./errors.js";
2
1
  import { styleText } from "node:util";
3
2
 
4
3
  /*
@@ -158,35 +157,24 @@ export function workspaceSlug(value) {
158
157
  }
159
158
 
160
159
  /**
161
- * A flag that must be a positive integer, refused rather than coerced.
160
+ * What the caller is looking at, and how to see the rest of it.
162
161
  *
163
- * `Number("foo")` is `NaN`, and `JSON.stringify({n: NaN})` is `{"n":null}` —
164
- * which on `epicNumber` is the value that *detaches* the epic. So a typo in
165
- * `--epic` silently did the opposite of what was asked. A bad `--limit` was
166
- * milder (the server falls back to its default) but equally silent.
167
- */
168
- export function positiveInt(value, flag) {
169
- if (value === undefined || value === null || value === "") return undefined;
170
- const n = Number(String(value).trim());
171
- if (!Number.isInteger(n) || n < 1) {
172
- throw new CliError(`--${flag} takes a positive whole number, not "${value}".`);
173
- }
174
- return n;
175
- }
176
-
177
- /**
178
- * An explicitly-given flag value, where empty means "clear it", not "absent".
179
- *
180
- * `parseArgs` reports `--description ""` and `--description=` as `""`, which is
181
- * falsy — so a truthiness gate dropped the flag and the field was left untouched
182
- * instead of cleared. `config.js` already states the rule for credentials ("an
183
- * explicit empty value is an error, not an absence"); this is the same
184
- * distinction for fields where empty is a legitimate instruction rather than a
185
- * mistake.
162
+ * `total` on a paged read is the unpaginated match count, so a page that is a
163
+ * slice of a larger answer has to say so: a table that stops at fifty rows and
164
+ * says nothing reads as the whole result, which is exactly how an agent told to
165
+ * check for duplicates first decides there are none. The next page is named as
166
+ * the flag that fetches it, because the useful answer to "there is more" is the
167
+ * command.
186
168
  *
187
- * Returns `undefined` when the flag was not given at all, so a caller can tell
188
- * the three cases apart.
169
+ * @param {{total?: unknown}} res the response envelope
170
+ * @param {number} shown rows printed
171
+ * @param {number|undefined} offset the offset this page was read at
189
172
  */
190
- export function givenFlag(value) {
191
- return value === undefined ? undefined : String(value);
173
+ export function pageLine(res, shown, offset = 0) {
174
+ const total = typeof res?.total === "number" ? res.total : null;
175
+ const from = offset ?? 0;
176
+ if (total === null) return `${shown} shown.`;
177
+ const last = from + shown;
178
+ if (last >= total) return from === 0 ? `${shown} of ${total}.` : `${from + 1}–${last} of ${total}.`;
179
+ return `${from + 1}–${last} of ${total} — next page: --offset ${last}`;
192
180
  }