@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/flags.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { CliError } from "./errors.js";
|
|
2
|
+
import { flagsFor, valuesOf } from "./spec.js";
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* What the caller typed: checked against the spec, then coerced.
|
|
6
|
+
*
|
|
7
|
+
* `parseArgs` lexes every flag the CLI has, because it runs before the command
|
|
8
|
+
* is known. That is the whole reason this file exists: without it, `zumino find
|
|
9
|
+
* --priority high` parses perfectly, reaches the handler, is never read, and
|
|
10
|
+
* prints an unfiltered list that looks exactly like a filtered one. A flag the
|
|
11
|
+
* command in hand does not accept is a *refusal*, and so is a value outside the
|
|
12
|
+
* set the API will act on.
|
|
13
|
+
*
|
|
14
|
+
* Both checks read `spec.js`, which is also what the help pages are rendered
|
|
15
|
+
* from — so a flag cannot be documented and unvalidated, or validated and
|
|
16
|
+
* undocumented.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Refuse a flag this command does not take, or a value it cannot mean.
|
|
21
|
+
*
|
|
22
|
+
* @param {any} leaf the spec entry for the command being run
|
|
23
|
+
* @param {Record<string, any>} flags `parseArgs` values
|
|
24
|
+
* @param {string} path how the command is written, for the message
|
|
25
|
+
*/
|
|
26
|
+
export function validateFlags(leaf, flags, path) {
|
|
27
|
+
const allowed = flagsFor(leaf);
|
|
28
|
+
|
|
29
|
+
for (const name of Object.keys(flags)) {
|
|
30
|
+
// `--version` and `--help` are answered before dispatch, on every command.
|
|
31
|
+
if (name === "version" || name === "help") continue;
|
|
32
|
+
|
|
33
|
+
// A flag that exists elsewhere in the CLI and is a plausible mistake here
|
|
34
|
+
// gets the explanation rather than the list. Declared per command, because
|
|
35
|
+
// what makes `--status` wrong on `find` is a property of that read.
|
|
36
|
+
const reject = leaf?.rejects?.[name];
|
|
37
|
+
if (reject) throw new CliError(reject.message, { hint: reject.hint });
|
|
38
|
+
|
|
39
|
+
const flag = allowed[name];
|
|
40
|
+
if (!flag) {
|
|
41
|
+
const offered = Object.entries(allowed)
|
|
42
|
+
.filter(([, f]) => !f.global)
|
|
43
|
+
.map(([n]) => `--${n}`);
|
|
44
|
+
throw new CliError(`${path} has no --${name}.`, {
|
|
45
|
+
hint: offered.length
|
|
46
|
+
? `It takes: ${offered.join(" ")}. See: zumino help ${path}`
|
|
47
|
+
: `It takes no flags of its own. See: zumino help ${path}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Checked here as well as where it is read, so `--limit 0` is refused
|
|
52
|
+
// before a project is resolved and a token is spent on a call that cannot
|
|
53
|
+
// succeed. The coercion at the call site is what produces the number.
|
|
54
|
+
if (flag.numeric === "positive") positiveInt(flags[name], name);
|
|
55
|
+
if (flag.numeric === "nonNegative") nonNegativeInt(flags[name], name);
|
|
56
|
+
|
|
57
|
+
// `--assignee someone@example.com` is a wrong *kind* of value, which is this
|
|
58
|
+
// layer's question — and answering it here means the message arrives before
|
|
59
|
+
// "No Zumino account is configured", which is what a caller sees first
|
|
60
|
+
// otherwise and is the less useful of the two.
|
|
61
|
+
if (flag.shape === "user") {
|
|
62
|
+
for (const given of listValues(flags, name) ?? []) {
|
|
63
|
+
assertUserRef(given, { flag: `--${name}`, allowClear: Boolean(flag.clearable) });
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/*
|
|
68
|
+
* `--status ""` and `--status=` are *given* and carry nothing.
|
|
69
|
+
*
|
|
70
|
+
* Left alone they are the original defect in miniature: the value list comes
|
|
71
|
+
* out empty, no filter is sent, and the answer is an unfiltered list at exit
|
|
72
|
+
* 0. An empty value is only ever an instruction where the field can be
|
|
73
|
+
* cleared — `--size ""` on a patch — and never on a filter or a closed
|
|
74
|
+
* vocabulary.
|
|
75
|
+
*/
|
|
76
|
+
if ((valuesOf(flag) || flag.shape) && !flag.clearable) {
|
|
77
|
+
const given = listValues(flags, name);
|
|
78
|
+
if (given && given.length === 0) {
|
|
79
|
+
throw new CliError(`--${name} was given no value.`, {
|
|
80
|
+
hint: `See: zumino help ${path}`,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const values = valuesOf(flag);
|
|
86
|
+
if (!values) continue;
|
|
87
|
+
for (const given of listValues(flags, name) ?? []) {
|
|
88
|
+
// A clearable field accepts `-` as "set this back to nothing", which is
|
|
89
|
+
// the same spelling `zumino task assign … -` uses.
|
|
90
|
+
if (flag.clearable && (given === "-" || given === "")) continue;
|
|
91
|
+
if (!values.includes(given)) {
|
|
92
|
+
throw new CliError(`--${name} takes ${values.join(", ")} — not "${given}".`, {
|
|
93
|
+
hint: `See: zumino help ${path}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every value of a repeatable flag, flattened.
|
|
102
|
+
*
|
|
103
|
+
* `--status todo --status shaping` and `--status todo,shaping` are the same
|
|
104
|
+
* thing, because the API accepts both spellings of the same filter. Empty
|
|
105
|
+
* segments are dropped, so a trailing comma is not a value the server would
|
|
106
|
+
* silently ignore.
|
|
107
|
+
*
|
|
108
|
+
* @returns {string[]|undefined} `undefined` when the flag was not given
|
|
109
|
+
*/
|
|
110
|
+
export function listValues(flags, name) {
|
|
111
|
+
const raw = flags[name];
|
|
112
|
+
if (raw === undefined) return undefined;
|
|
113
|
+
const all = (Array.isArray(raw) ? raw : [raw])
|
|
114
|
+
.flatMap((v) => String(v).split(","))
|
|
115
|
+
.map((v) => v.trim())
|
|
116
|
+
.filter((v) => v.length > 0);
|
|
117
|
+
return all;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The single value of a flag that is repeatable in the parser but not here.
|
|
122
|
+
*
|
|
123
|
+
* `--status` is a list on `task list` and one value on `task create`. Without
|
|
124
|
+
* this, the second `--status` would silently win — the same
|
|
125
|
+
* quietly-dropped-input failure this file exists to stop, one layer down.
|
|
126
|
+
*
|
|
127
|
+
* @returns {string|undefined}
|
|
128
|
+
*/
|
|
129
|
+
export function oneValue(flags, name) {
|
|
130
|
+
const raw = flags[name];
|
|
131
|
+
if (raw === undefined) return undefined;
|
|
132
|
+
if (!Array.isArray(raw)) return String(raw);
|
|
133
|
+
if (raw.length > 1) {
|
|
134
|
+
throw new CliError(`--${name} takes one value here, and was given ${raw.length}.`);
|
|
135
|
+
}
|
|
136
|
+
return raw.length ? String(raw[0]) : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A flag that must be a positive integer, refused rather than coerced.
|
|
141
|
+
*
|
|
142
|
+
* `Number("foo")` is `NaN`, and `JSON.stringify({n: NaN})` is `{"n":null}` —
|
|
143
|
+
* which on `epicNumber` is the value that *detaches* the epic. So a typo in
|
|
144
|
+
* `--epic` silently did the opposite of what was asked. A bad `--limit` was
|
|
145
|
+
* milder (the server falls back to its default) but equally silent.
|
|
146
|
+
*/
|
|
147
|
+
export function positiveInt(value, flag) {
|
|
148
|
+
return boundedInt(value, flag, 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Same, for `--offset`, where zero is the first page rather than an error. */
|
|
152
|
+
export function nonNegativeInt(value, flag) {
|
|
153
|
+
return boundedInt(value, flag, 0);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function boundedInt(value, flag, min) {
|
|
157
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
158
|
+
const raw = Array.isArray(value) ? value[value.length - 1] : value;
|
|
159
|
+
const n = Number(String(raw).trim());
|
|
160
|
+
if (!Number.isInteger(n) || n < min) {
|
|
161
|
+
throw new CliError(
|
|
162
|
+
`--${flag} takes a whole number${min === 1 ? " of 1 or more" : " of 0 or more"}, not "${raw}".`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
return n;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An explicitly-given flag value, where empty means "clear it", not "absent".
|
|
170
|
+
*
|
|
171
|
+
* `parseArgs` reports `--description ""` and `--description=` as `""`, which is
|
|
172
|
+
* falsy — so a truthiness gate dropped the flag and the field was left untouched
|
|
173
|
+
* instead of cleared. `config.js` already states the rule for credentials ("an
|
|
174
|
+
* explicit empty value is an error, not an absence"); this is the same
|
|
175
|
+
* distinction for fields where empty is a legitimate instruction rather than a
|
|
176
|
+
* mistake.
|
|
177
|
+
*
|
|
178
|
+
* Returns `undefined` when the flag was not given at all, so a caller can tell
|
|
179
|
+
* the three cases apart.
|
|
180
|
+
*/
|
|
181
|
+
export function givenFlag(value) {
|
|
182
|
+
if (value === undefined) return undefined;
|
|
183
|
+
return Array.isArray(value) ? String(value[value.length - 1]) : String(value);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The shape of a value that names a person, checked without asking the server.
|
|
188
|
+
*
|
|
189
|
+
* The API takes a **user id** everywhere a person is named, which is right and
|
|
190
|
+
* also unusable by hand — so the two values somebody actually has are both wrong,
|
|
191
|
+
* and each is wrong misleadingly:
|
|
192
|
+
*
|
|
193
|
+
* - an **email** is looked up as an id and refused with "not a member of this
|
|
194
|
+
* workspace", which reads as "that person has no access" rather than "that is
|
|
195
|
+
* the wrong kind of value";
|
|
196
|
+
* - **`me`** is the one a script wants most and nothing accepted.
|
|
197
|
+
*
|
|
198
|
+
* This is the pure half: it classifies what was typed and refuses an email. It
|
|
199
|
+
* runs in `validateFlags` and, for `zumino task assign`, before the context is
|
|
200
|
+
* resolved at all — the shape of an argument is wrong whether or not a credential
|
|
201
|
+
* exists, and "No Zumino account is configured" is the less useful of the two
|
|
202
|
+
* answers. `resolveUser` in `users.js` is the half that makes the call.
|
|
203
|
+
*
|
|
204
|
+
* @param {string} value a user id, `me`, or `-`/`none`
|
|
205
|
+
* @param {{flag: string, allowClear?: boolean}} opts
|
|
206
|
+
* @returns {{kind: "clear"|"me"|"id", id?: string}}
|
|
207
|
+
*/
|
|
208
|
+
export function assertUserRef(value, { flag, allowClear = false }) {
|
|
209
|
+
const v = String(value ?? "").trim();
|
|
210
|
+
|
|
211
|
+
if (v === "-" || v === "" || v === "none") {
|
|
212
|
+
if (allowClear) return { kind: "clear" };
|
|
213
|
+
throw new CliError(`--${flag} needs a person: a user id, or "me".`, {
|
|
214
|
+
hint: "A filter cannot ask for nobody — the API has no unassigned filter.",
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
if (v === "me") return { kind: "me" };
|
|
218
|
+
if (/@/.test(v)) {
|
|
219
|
+
throw new CliError(`"${v}" looks like an email; ${flag} takes a user id.`, {
|
|
220
|
+
hint:
|
|
221
|
+
"Find one on an item you can read: " +
|
|
222
|
+
"zumino task show <CODE> --json | jq -r '.assignee.id'",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return { kind: "id", id: v };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The comma-joined form the API's repeatable filters take.
|
|
230
|
+
*
|
|
231
|
+
* `?status=todo,shaping` rather than `?status=todo&status=shaping`: the server
|
|
232
|
+
* accepts either, and one parameter keeps the URL a person can read in a log.
|
|
233
|
+
*/
|
|
234
|
+
export function csv(flags, name) {
|
|
235
|
+
const all = listValues(flags, name);
|
|
236
|
+
return all?.length ? all.join(",") : undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The free text a list command searches for, given either way.
|
|
241
|
+
*
|
|
242
|
+
* `zumino find "rate limit"` and `zumino find --query "rate limit"` are the same
|
|
243
|
+
* call. Giving both is refused rather than resolved by precedence: one of the
|
|
244
|
+
* two was going to be dropped, and a search that quietly answers a different
|
|
245
|
+
* question than the one asked is the failure this whole module is about.
|
|
246
|
+
*
|
|
247
|
+
* @param {string[]} args positionals left after the subcommand
|
|
248
|
+
* @param {Record<string, any>} flags
|
|
249
|
+
*/
|
|
250
|
+
export function searchText(args, flags) {
|
|
251
|
+
const positional = args.join(" ").trim();
|
|
252
|
+
const flagged = oneValue(flags, "query")?.trim();
|
|
253
|
+
if (positional && flagged !== undefined && flagged !== positional) {
|
|
254
|
+
throw new CliError("Give the search text once — as an argument or as --query, not both.");
|
|
255
|
+
}
|
|
256
|
+
return (flagged ?? positional) || undefined;
|
|
257
|
+
}
|
package/src/help.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import {
|
|
2
|
+
COMMANDS,
|
|
3
|
+
FLAGS,
|
|
4
|
+
GLOBAL_FLAGS,
|
|
5
|
+
GROUPS,
|
|
6
|
+
command,
|
|
7
|
+
flagsFor,
|
|
8
|
+
leaves,
|
|
9
|
+
subcommand,
|
|
10
|
+
valuesOf,
|
|
11
|
+
} from "./spec.js";
|
|
12
|
+
import { bold, dim } from "./output.js";
|
|
13
|
+
|
|
14
|
+
/*
|
|
15
|
+
* Every page the CLI prints about itself, rendered from `spec.js`.
|
|
16
|
+
*
|
|
17
|
+
* Nothing here is written twice. A flag appears on a help page because the
|
|
18
|
+
* command declares it, and the validator refuses it because the command does
|
|
19
|
+
* not — the same line of the same file answers both, which is the only way a
|
|
20
|
+
* `--help` page stays true after the tenth flag is added.
|
|
21
|
+
*
|
|
22
|
+
* Three audiences, three shapes, one source:
|
|
23
|
+
*
|
|
24
|
+
* - `overview()` for somebody who typed `zumino` and wants to see the shape of
|
|
25
|
+
* it — every command, grouped, one line each.
|
|
26
|
+
* - `page()` for somebody who knows which command and needs the accepted
|
|
27
|
+
* values — the thing a flat usage string cannot hold.
|
|
28
|
+
* - `flat()` and `surface()` for a program. `surface()` is the declaration
|
|
29
|
+
* itself, so an agent can discover the vocabularies (`values.taskStatus`)
|
|
30
|
+
* rather than guessing them and reading a 400 back.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const WIDTH = 78;
|
|
34
|
+
|
|
35
|
+
/** Wrap prose to the terminal, indenting every line including the first. */
|
|
36
|
+
function wrap(text, indent = " ", width = WIDTH) {
|
|
37
|
+
const words = String(text).split(/\s+/).filter(Boolean);
|
|
38
|
+
const lines = [];
|
|
39
|
+
let line = "";
|
|
40
|
+
for (const word of words) {
|
|
41
|
+
if (line && (indent + line + " " + word).length > width) {
|
|
42
|
+
lines.push(indent + line);
|
|
43
|
+
line = word;
|
|
44
|
+
} else {
|
|
45
|
+
line = line ? `${line} ${word}` : word;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (line) lines.push(indent + line);
|
|
49
|
+
return lines.join("\n");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const pad = (s, n) => s + " ".repeat(Math.max(1, n - s.length));
|
|
53
|
+
|
|
54
|
+
/** `--status STATUS`, as it is written on a page. */
|
|
55
|
+
function flagSyntax(name, flag) {
|
|
56
|
+
return flag.arg ? `--${name} ${flag.arg}` : `--${name}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The global flags, on one line, for the bottom of a command page. */
|
|
60
|
+
function globalLine() {
|
|
61
|
+
return GLOBAL_FLAGS.filter((n) => n !== "help")
|
|
62
|
+
.map((n) => flagSyntax(n, FLAGS[n]))
|
|
63
|
+
.join(" ");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** `zumino --help`: the whole surface, one line per command. */
|
|
67
|
+
export function overview() {
|
|
68
|
+
const lines = [
|
|
69
|
+
`${bold("zumino")} — drive Zumino from a terminal, a script, or an agent.`,
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
for (const [id, heading] of GROUPS) {
|
|
73
|
+
const group = COMMANDS.filter((c) => (c.group ?? "meta") === id);
|
|
74
|
+
if (group.length === 0) continue;
|
|
75
|
+
lines.push("", bold(heading));
|
|
76
|
+
for (const c of group) {
|
|
77
|
+
lines.push(` ${pad(c.name, 14)}${c.summary}`);
|
|
78
|
+
if (c.subcommands) {
|
|
79
|
+
lines.push(dim(` ${" ".repeat(14)}${c.subcommands.map((s) => s.name).join(" · ")}`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
lines.push(
|
|
85
|
+
"",
|
|
86
|
+
bold("One command in full"),
|
|
87
|
+
` zumino help <command> [<subcommand>] ${dim("e.g. zumino help task list")}`,
|
|
88
|
+
` zumino commands [--json] ${dim("every command at once, for a program")}`,
|
|
89
|
+
"",
|
|
90
|
+
bold("Context") +
|
|
91
|
+
` is resolved in this order, and ${dim("zumino auth status")} says which won:`,
|
|
92
|
+
" --token/--account → ZUMINO_TOKEN → .zumino.json → repo map → sole stored account",
|
|
93
|
+
"",
|
|
94
|
+
`${bold("Global flags")} ${globalLine()}`,
|
|
95
|
+
`${bold("Env")} ZUMINO_TOKEN ZUMINO_URL ZUMINO_PROJECT ZUMINO_WORKSPACE`,
|
|
96
|
+
` ZUMINO_ACCOUNT ZUMINO_NO_UPDATE_CHECK`,
|
|
97
|
+
`${bold("Exit")} 0 ok · 1 failed · 2 nothing resolved · 3 CLI too old`,
|
|
98
|
+
);
|
|
99
|
+
return lines.join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The page for one group — its subcommands and what each is for. */
|
|
103
|
+
function groupPage(group) {
|
|
104
|
+
const width = Math.max(...group.subcommands.map((s) => s.name.length)) + 2;
|
|
105
|
+
return [
|
|
106
|
+
`${bold(`zumino ${group.name}`)} — ${group.summary}`,
|
|
107
|
+
"",
|
|
108
|
+
...group.subcommands.map((s) => ` ${pad(s.name, width)}${s.summary}`),
|
|
109
|
+
"",
|
|
110
|
+
dim(` zumino help ${group.name} <subcommand> for one in full`),
|
|
111
|
+
].join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The page for one command: usage, arguments, every flag with the values it
|
|
116
|
+
* accepts, why it is shaped the way it is, and examples that run.
|
|
117
|
+
*/
|
|
118
|
+
function leafPage(leaf, path) {
|
|
119
|
+
const lines = [`${bold(`zumino ${path}`)} — ${leaf.summary}`];
|
|
120
|
+
|
|
121
|
+
lines.push("", bold("Usage"), ` ${leaf.usage ?? `zumino ${path}`}`);
|
|
122
|
+
|
|
123
|
+
if (leaf.args?.length) {
|
|
124
|
+
const width = Math.max(...leaf.args.map(([a]) => a.length)) + 2;
|
|
125
|
+
lines.push("", bold("Arguments"));
|
|
126
|
+
for (const [arg, help] of leaf.args) lines.push(` ${pad(arg, width)}${help}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const own = Object.entries(flagsFor(leaf)).filter(([, f]) => !f.global);
|
|
130
|
+
if (own.length) {
|
|
131
|
+
const width = Math.max(...own.map(([n, f]) => flagSyntax(n, f).length)) + 2;
|
|
132
|
+
lines.push("", bold("Flags"));
|
|
133
|
+
for (const [name, flag] of own) {
|
|
134
|
+
lines.push(` ${pad(flagSyntax(name, flag), width)}${flag.help ?? ""}`);
|
|
135
|
+
const values = valuesOf(flag);
|
|
136
|
+
if (values) {
|
|
137
|
+
const listed = flag.clearable ? [...values, "-"] : values;
|
|
138
|
+
lines.push(dim(` ${" ".repeat(width)}${listed.join(" · ")}`));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
lines.push("", dim(` Global: ${globalLine()}`));
|
|
143
|
+
|
|
144
|
+
if (leaf.notes) lines.push("", bold("Notes"), wrap(leaf.notes));
|
|
145
|
+
|
|
146
|
+
if (leaf.examples?.length) {
|
|
147
|
+
lines.push("", bold("Examples"));
|
|
148
|
+
for (const ex of leaf.examples) lines.push(` ${ex}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The most specific page for what was typed.
|
|
156
|
+
*
|
|
157
|
+
* Returns `null` when the name is not a command, so the caller can say so with
|
|
158
|
+
* a suggestion rather than printing a page for something else.
|
|
159
|
+
*
|
|
160
|
+
* @param {string[]} words e.g. `["task", "list"]`
|
|
161
|
+
*/
|
|
162
|
+
export function page(words) {
|
|
163
|
+
const [name, sub] = words;
|
|
164
|
+
if (!name) return overview();
|
|
165
|
+
const top = command(name);
|
|
166
|
+
if (!top) return null;
|
|
167
|
+
if (!top.subcommands) return leafPage(top, top.name);
|
|
168
|
+
if (!sub) return groupPage(top);
|
|
169
|
+
const leaf = subcommand(top, sub);
|
|
170
|
+
if (!leaf) return null;
|
|
171
|
+
return leafPage(leaf, `${top.name} ${sub}`);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* What to suggest when a name is not a command.
|
|
176
|
+
*
|
|
177
|
+
* The plural is the guess people and agents actually make — `zumino projects`
|
|
178
|
+
* for `zumino project list` — so it is answered with the command rather than
|
|
179
|
+
* with the whole help page.
|
|
180
|
+
*/
|
|
181
|
+
export function suggest(name) {
|
|
182
|
+
const all = leaves();
|
|
183
|
+
const bare = String(name ?? "").toLowerCase();
|
|
184
|
+
const singular = bare.replace(/s$/, "");
|
|
185
|
+
|
|
186
|
+
if (bare === "whoami" || bare === "me") return "zumino auth status";
|
|
187
|
+
if (bare === "login" || bare === "logout") return `zumino auth ${bare}`;
|
|
188
|
+
if (bare === "ls" || bare === "list" || bare === "search") return "zumino find";
|
|
189
|
+
if (bare === "show" || bare === "get" || bare === "view") return "zumino task show <CODE>";
|
|
190
|
+
|
|
191
|
+
// A group whose list is what was probably meant: `projects` → `project list`.
|
|
192
|
+
const group = COMMANDS.find((c) => c.name === singular && c.subcommands);
|
|
193
|
+
if (group && subcommand(group, "list")) return `zumino ${group.name} list`;
|
|
194
|
+
|
|
195
|
+
const near = all.find((l) => l.path.startsWith(bare) || l.name === singular);
|
|
196
|
+
return near ? `zumino ${near.path}` : null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `zumino commands`: every leaf, one line each, for grepping. */
|
|
200
|
+
export function flat() {
|
|
201
|
+
const all = leaves();
|
|
202
|
+
const width = Math.max(...all.map((l) => l.path.length)) + 2;
|
|
203
|
+
return all.map((l) => `${pad(l.path, width)}${l.summary}`).join("\n");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `zumino commands --json`: the declaration itself.
|
|
208
|
+
*
|
|
209
|
+
* The shape a program should read before driving this CLI. `values` is the point
|
|
210
|
+
* of it — the accepted set of every enumerated filter, so a caller does not
|
|
211
|
+
* discover `in_review` by having `--status review` refused.
|
|
212
|
+
*
|
|
213
|
+
* @param {{version: string, values: Record<string, string[]>}} opts
|
|
214
|
+
*/
|
|
215
|
+
export function surface({ version, values }) {
|
|
216
|
+
return {
|
|
217
|
+
cli: version,
|
|
218
|
+
values,
|
|
219
|
+
global: GLOBAL_FLAGS.filter((n) => n !== "help").map((name) => ({
|
|
220
|
+
name,
|
|
221
|
+
arg: FLAGS[name].arg ?? null,
|
|
222
|
+
type: FLAGS[name].type,
|
|
223
|
+
help: FLAGS[name].help,
|
|
224
|
+
})),
|
|
225
|
+
env: [
|
|
226
|
+
"ZUMINO_TOKEN",
|
|
227
|
+
"ZUMINO_URL",
|
|
228
|
+
"ZUMINO_PROJECT",
|
|
229
|
+
"ZUMINO_WORKSPACE",
|
|
230
|
+
"ZUMINO_ACCOUNT",
|
|
231
|
+
"ZUMINO_NO_UPDATE_CHECK",
|
|
232
|
+
],
|
|
233
|
+
exitCodes: {
|
|
234
|
+
0: "ok",
|
|
235
|
+
1: "failed",
|
|
236
|
+
2: "nothing resolved — no token, project or workspace",
|
|
237
|
+
3: "this CLI is too old for the server; run zumino self-update",
|
|
238
|
+
},
|
|
239
|
+
commands: leaves().map((leaf) => ({
|
|
240
|
+
path: leaf.path,
|
|
241
|
+
summary: leaf.summary,
|
|
242
|
+
usage: leaf.usage ?? `zumino ${leaf.path}`,
|
|
243
|
+
args: (leaf.args ?? []).map(([name, help]) => ({ name, help })),
|
|
244
|
+
flags: Object.entries(flagsFor(leaf))
|
|
245
|
+
.filter(([, f]) => !f.global)
|
|
246
|
+
.map(([name, f]) => ({
|
|
247
|
+
name,
|
|
248
|
+
arg: f.arg ?? null,
|
|
249
|
+
type: f.type,
|
|
250
|
+
repeatable: Boolean(f.multiple),
|
|
251
|
+
values: valuesOf(f),
|
|
252
|
+
clearable: Boolean(f.clearable),
|
|
253
|
+
help: f.help ?? null,
|
|
254
|
+
})),
|
|
255
|
+
notes: leaf.notes ?? null,
|
|
256
|
+
examples: leaf.examples ?? [],
|
|
257
|
+
})),
|
|
258
|
+
};
|
|
259
|
+
}
|