@forwardimpact/libcli 0.1.7 → 0.1.9
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 +6 -1
- package/package.json +23 -20
- package/src/cli.js +57 -16
- package/src/format.js +2 -2
- package/src/help.js +19 -6
- package/src/index.js +1 -0
- package/src/invocation-context.js +54 -0
- package/src/summary.js +64 -8
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
# libcli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<!-- BEGIN:description — Do not edit. Generated from package.json. -->
|
|
4
|
+
|
|
5
|
+
Agent-friendly CLIs — self-documenting entry points that humans and agents reach
|
|
6
|
+
through the same interface.
|
|
7
|
+
|
|
8
|
+
<!-- END:description -->
|
|
4
9
|
|
|
5
10
|
## Getting Started
|
|
6
11
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libcli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Agent-friendly CLIs
|
|
3
|
+
"version": "0.1.9",
|
|
4
|
+
"description": "Agent-friendly CLIs — self-documenting entry points that humans and agents reach through the same interface.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
7
7
|
"agent",
|
|
@@ -9,16 +9,24 @@
|
|
|
9
9
|
"help",
|
|
10
10
|
"grep"
|
|
11
11
|
],
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
"Surface skill-doc links in CLI --help output"
|
|
18
|
-
]
|
|
12
|
+
"homepage": "https://www.forwardimpact.team",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/forwardimpact/monorepo.git",
|
|
16
|
+
"directory": "libraries/libcli"
|
|
19
17
|
},
|
|
20
18
|
"license": "Apache-2.0",
|
|
21
19
|
"author": "D. Olsson <hi@senzilla.io>",
|
|
20
|
+
"jobs": [
|
|
21
|
+
{
|
|
22
|
+
"user": "Platform Builders",
|
|
23
|
+
"goal": "Enable Agents on Every Surface",
|
|
24
|
+
"trigger": "Building an interface and realizing agents can't discover or navigate it the same way humans do.",
|
|
25
|
+
"bigHire": "give agents and humans the same interface so capabilities don't need separate paths.",
|
|
26
|
+
"littleHire": "add a capability and know both humans and agents can reach it without a separate integration.",
|
|
27
|
+
"competesWith": "hand-written argument parsing; separate agent and human interfaces; tolerating agents that can't self-serve"
|
|
28
|
+
}
|
|
29
|
+
],
|
|
22
30
|
"type": "module",
|
|
23
31
|
"main": "./src/index.js",
|
|
24
32
|
"exports": {
|
|
@@ -28,22 +36,17 @@
|
|
|
28
36
|
"src/**/*.js",
|
|
29
37
|
"README.md"
|
|
30
38
|
],
|
|
31
|
-
"engines": {
|
|
32
|
-
"bun": ">=1.2.0",
|
|
33
|
-
"node": ">=18.0.0"
|
|
34
|
-
},
|
|
35
39
|
"scripts": {
|
|
36
40
|
"test": "bun test test/*.test.js"
|
|
37
41
|
},
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@forwardimpact/libharness": "^0.1.14"
|
|
44
|
+
},
|
|
45
|
+
"engines": {
|
|
46
|
+
"bun": ">=1.2.0",
|
|
47
|
+
"node": ">=18.0.0"
|
|
42
48
|
},
|
|
43
49
|
"publishConfig": {
|
|
44
50
|
"access": "public"
|
|
45
|
-
},
|
|
46
|
-
"devDependencies": {
|
|
47
|
-
"@forwardimpact/libharness": "^0.1.14"
|
|
48
51
|
}
|
|
49
52
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { HelpRenderer } from "./help.js";
|
|
3
|
+
import { freezeInvocationContext } from "./invocation-context.js";
|
|
3
4
|
|
|
5
|
+
/** Command-line interface that parses argv against a definition of commands, options, and help. */
|
|
4
6
|
export class Cli {
|
|
5
7
|
#definition;
|
|
6
8
|
#proc;
|
|
7
9
|
#helpRenderer;
|
|
8
10
|
|
|
11
|
+
/** Build a CLI from a definition, wiring in the process handle and help renderer; throws if the deprecated top-level `options` key is present or if any command option name collides with a global option. */
|
|
9
12
|
constructor(definition, { process, helpRenderer }) {
|
|
10
13
|
if (definition.options) {
|
|
11
14
|
throw new Error(
|
|
@@ -14,29 +17,34 @@ export class Cli {
|
|
|
14
17
|
`for command-specific options.`,
|
|
15
18
|
);
|
|
16
19
|
}
|
|
17
|
-
|
|
18
|
-
const globalNames = new Set(Object.keys(definition.globalOptions));
|
|
19
|
-
for (const cmd of definition.commands) {
|
|
20
|
-
if (!cmd.options) continue;
|
|
21
|
-
for (const name of Object.keys(cmd.options)) {
|
|
22
|
-
if (globalNames.has(name)) {
|
|
23
|
-
throw new Error(
|
|
24
|
-
`${definition.name}: option "${name}" in command ` +
|
|
25
|
-
`"${cmd.name}" collides with a global option`,
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
20
|
+
Cli.#validateNoCollisions(definition);
|
|
31
21
|
this.#definition = definition;
|
|
32
22
|
this.#proc = process;
|
|
33
23
|
this.#helpRenderer = helpRenderer;
|
|
34
24
|
}
|
|
35
25
|
|
|
26
|
+
static #validateNoCollisions(definition) {
|
|
27
|
+
if (!definition.commands || !definition.globalOptions) return;
|
|
28
|
+
const globalNames = new Set(Object.keys(definition.globalOptions));
|
|
29
|
+
for (const cmd of definition.commands) {
|
|
30
|
+
if (!cmd.options) continue;
|
|
31
|
+
for (const name of Object.keys(cmd.options)) {
|
|
32
|
+
if (globalNames.has(name)) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`${definition.name}: option "${name}" in command ` +
|
|
35
|
+
`"${cmd.name}" collides with a global option`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Return the CLI program name from the definition. */
|
|
36
43
|
get name() {
|
|
37
44
|
return this.#definition.name;
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
/** Parse argv into values and positionals, handling --help and --version; returns null when help or version is printed. */
|
|
40
48
|
parse(argv) {
|
|
41
49
|
const command = this.#findCommand(argv);
|
|
42
50
|
const options = this.#buildOptions(command);
|
|
@@ -88,8 +96,11 @@ export class Cli {
|
|
|
88
96
|
const bare = match[2];
|
|
89
97
|
const asCommand = this.#definition.commands?.find((c) => c.name === bare);
|
|
90
98
|
if (!asCommand) return null;
|
|
91
|
-
const
|
|
92
|
-
?
|
|
99
|
+
const argsStr = Array.isArray(asCommand.args)
|
|
100
|
+
? asCommand.argsUsage
|
|
101
|
+
: asCommand.args;
|
|
102
|
+
const usage = argsStr
|
|
103
|
+
? `${this.#definition.name} ${bare} ${argsStr}`
|
|
93
104
|
: `${this.#definition.name} ${bare}`;
|
|
94
105
|
return new Error(
|
|
95
106
|
`Unknown option "${match[1]}${bare}". "${bare}" is a command, not an option. Usage: ${usage}`,
|
|
@@ -123,21 +134,51 @@ export class Cli {
|
|
|
123
134
|
return null;
|
|
124
135
|
}
|
|
125
136
|
|
|
137
|
+
/** Match parsed positionals to a subcommand and invoke its handler with a frozen invocation context. */
|
|
138
|
+
dispatch(parsed, { data }) {
|
|
139
|
+
const command = this.#findCommand(parsed.positionals);
|
|
140
|
+
if (!command) {
|
|
141
|
+
throw new Error(`${this.#definition.name}: no matching subcommand`);
|
|
142
|
+
}
|
|
143
|
+
if (typeof command.handler !== "function") {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`${this.#definition.name}: subcommand "${command.name}" lacks a handler — ` +
|
|
146
|
+
`dispatch() requires { args: string[], handler: (ctx) => any }`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const consumed = command.name.split(" ").length;
|
|
150
|
+
const argv = parsed.positionals.slice(consumed);
|
|
151
|
+
const argNames = Array.isArray(command.args) ? command.args : [];
|
|
152
|
+
const args = Object.fromEntries(
|
|
153
|
+
argNames.map((n, i) => [n, argv[i]]).filter(([, v]) => v !== undefined),
|
|
154
|
+
);
|
|
155
|
+
const ctx = freezeInvocationContext({
|
|
156
|
+
data,
|
|
157
|
+
args,
|
|
158
|
+
options: parsed.values,
|
|
159
|
+
});
|
|
160
|
+
return command.handler(ctx);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Print the top-level help text to stdout. */
|
|
126
164
|
showHelp() {
|
|
127
165
|
this.#helpRenderer.render(this.#definition, this.#proc.stdout);
|
|
128
166
|
}
|
|
129
167
|
|
|
168
|
+
/** Write an error message to stderr and set exit code 1. */
|
|
130
169
|
error(message) {
|
|
131
170
|
this.#proc.stderr.write(`${this.#definition.name}: error: ${message}\n`);
|
|
132
171
|
this.#proc.exitCode = 1;
|
|
133
172
|
}
|
|
134
173
|
|
|
174
|
+
/** Write a usage error message to stderr and set exit code 2. */
|
|
135
175
|
usageError(message) {
|
|
136
176
|
this.#proc.stderr.write(`${this.#definition.name}: error: ${message}\n`);
|
|
137
177
|
this.#proc.exitCode = 2;
|
|
138
178
|
}
|
|
139
179
|
}
|
|
140
180
|
|
|
181
|
+
/** Create a Cli instance wired to the real process and a default HelpRenderer. */
|
|
141
182
|
export function createCli(definition) {
|
|
142
183
|
const helpRenderer = new HelpRenderer({ process });
|
|
143
184
|
return new Cli(definition, { process, helpRenderer });
|
package/src/format.js
CHANGED
|
@@ -84,7 +84,7 @@ export function formatTable(headers, rows, options = {}, proc = process) {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
*
|
|
87
|
+
* Prefix the message with "Error: " and colorize red.
|
|
88
88
|
* @param {string} message
|
|
89
89
|
* @param {object} proc
|
|
90
90
|
* @returns {string}
|
|
@@ -104,7 +104,7 @@ export function formatSuccess(message, proc = process) {
|
|
|
104
104
|
}
|
|
105
105
|
|
|
106
106
|
/**
|
|
107
|
-
*
|
|
107
|
+
* Prefix the message with "Warning: " and colorize yellow.
|
|
108
108
|
* @param {string} message
|
|
109
109
|
* @param {object} proc
|
|
110
110
|
* @returns {string}
|
package/src/help.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { supportsColor } from "./color.js";
|
|
2
2
|
import { formatHeader, formatSubheader } from "./format.js";
|
|
3
3
|
|
|
4
|
+
/** Render CLI help output as formatted text or JSON from a CLI definition. */
|
|
4
5
|
export class HelpRenderer {
|
|
5
6
|
#proc;
|
|
6
7
|
|
|
8
|
+
/** Store the process handle for stdout access and color detection. */
|
|
7
9
|
constructor({ process }) {
|
|
8
10
|
this.#proc = process;
|
|
9
11
|
}
|
|
@@ -32,16 +34,23 @@ export class HelpRenderer {
|
|
|
32
34
|
return [`Usage: ${definition.name} [options]`, ""];
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
#argsDisplay(cmd) {
|
|
38
|
+
if (Array.isArray(cmd.args)) return cmd.argsUsage || "";
|
|
39
|
+
return cmd.args || "";
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
#renderCommands(definition) {
|
|
36
43
|
if (!definition.commands || definition.commands.length === 0) return [];
|
|
37
44
|
const lines = [this.#sectionHeader("Commands:")];
|
|
38
45
|
const maxWidth = Math.max(
|
|
39
|
-
...definition.commands.map(
|
|
40
|
-
|
|
41
|
-
|
|
46
|
+
...definition.commands.map((c) => {
|
|
47
|
+
const argsStr = this.#argsDisplay(c);
|
|
48
|
+
return c.name.length + (argsStr ? argsStr.length + 1 : 0);
|
|
49
|
+
}),
|
|
42
50
|
);
|
|
43
51
|
for (const cmd of definition.commands) {
|
|
44
|
-
const
|
|
52
|
+
const argsStr = this.#argsDisplay(cmd);
|
|
53
|
+
const left = argsStr ? `${cmd.name} ${argsStr}` : cmd.name;
|
|
45
54
|
lines.push(` ${left.padEnd(maxWidth)} ${cmd.description}`);
|
|
46
55
|
}
|
|
47
56
|
lines.push("");
|
|
@@ -106,15 +115,16 @@ export class HelpRenderer {
|
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
#renderCommand(definition, stream, command) {
|
|
118
|
+
const argsStr = this.#argsDisplay(command);
|
|
109
119
|
let header = `${definition.name} ${command.name}`;
|
|
110
|
-
if (
|
|
120
|
+
if (argsStr) header += ` ${argsStr}`;
|
|
111
121
|
if (command.description) header += ` \u2014 ${command.description}`;
|
|
112
122
|
const formatted = supportsColor(this.#proc)
|
|
113
123
|
? formatHeader(header, this.#proc)
|
|
114
124
|
: header;
|
|
115
125
|
|
|
116
126
|
let usage = `Usage: ${definition.name} ${command.name}`;
|
|
117
|
-
if (
|
|
127
|
+
if (argsStr) usage += ` ${argsStr}`;
|
|
118
128
|
usage += " [options]";
|
|
119
129
|
|
|
120
130
|
const globalWithoutVersion = definition.globalOptions
|
|
@@ -137,6 +147,7 @@ export class HelpRenderer {
|
|
|
137
147
|
stream.write(lines.join("\n"));
|
|
138
148
|
}
|
|
139
149
|
|
|
150
|
+
/** Render human-readable help text for the full CLI or a single command to the given stream. */
|
|
140
151
|
render(definition, stream, command) {
|
|
141
152
|
const out = stream || this.#proc.stdout;
|
|
142
153
|
if (command) {
|
|
@@ -155,6 +166,7 @@ export class HelpRenderer {
|
|
|
155
166
|
out.write(lines.join("\n"));
|
|
156
167
|
}
|
|
157
168
|
|
|
169
|
+
/** Render the CLI definition or a single command as pretty-printed JSON to the given stream. */
|
|
158
170
|
renderJson(definition, stream, command) {
|
|
159
171
|
const out = stream || this.#proc.stdout;
|
|
160
172
|
if (command) {
|
|
@@ -173,6 +185,7 @@ export class HelpRenderer {
|
|
|
173
185
|
options: command.options,
|
|
174
186
|
globalOptions: globalWithoutVersion,
|
|
175
187
|
examples: command.examples,
|
|
188
|
+
documentation: definition.documentation,
|
|
176
189
|
};
|
|
177
190
|
out.write(JSON.stringify(obj, null, 2) + "\n");
|
|
178
191
|
} else {
|
package/src/index.js
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {Object} InvocationContext
|
|
3
|
+
*
|
|
4
|
+
* The shape libui and libcli both produce from their native inputs.
|
|
5
|
+
* Handlers consume the context and return a view; surface-specific
|
|
6
|
+
* formatters render the view. The context carries no information about
|
|
7
|
+
* which surface produced it — surface dispatch happens one level above the
|
|
8
|
+
* handler.
|
|
9
|
+
*
|
|
10
|
+
* Invariants:
|
|
11
|
+
* - No surface affordances — no DOM nodes, streams, Request/Response, or
|
|
12
|
+
* surface tag. Anything that exists on only one surface stays out.
|
|
13
|
+
* - Uniform value shapes — args values are strings; options values are one
|
|
14
|
+
* of string, boolean true, or string[]. No nulls, no numbers.
|
|
15
|
+
* - Frozen at all levels — the context, args, options, and any array
|
|
16
|
+
* inside options are Object.freeze'd by the producer.
|
|
17
|
+
*
|
|
18
|
+
* @property {Object} data
|
|
19
|
+
* Host's data dependencies, opaque to libui and libcli. Shape is the
|
|
20
|
+
* product's responsibility. Anything a handler needs that is not a
|
|
21
|
+
* positional or named argument lives here, including surface-specific
|
|
22
|
+
* runtime dependencies the host folds in before invocation. The handler
|
|
23
|
+
* treats data as immutable input.
|
|
24
|
+
*
|
|
25
|
+
* @property {Readonly<Object<string, string>>} args
|
|
26
|
+
* Named positional arguments. On the web side: route-pattern parameters
|
|
27
|
+
* keyed by their name. On the CLI side: the subcommand's declared
|
|
28
|
+
* positional argument names mapped to their argv values. Values are
|
|
29
|
+
* always strings; consumers parse if they need other types.
|
|
30
|
+
*
|
|
31
|
+
* @property {Readonly<Object<string, string | boolean | string[]>>} options
|
|
32
|
+
* Named non-positional arguments. On the web side: the URL hash query
|
|
33
|
+
* string parsed once. On the CLI side: parsed CLI flags. Values are one
|
|
34
|
+
* of: a string, the boolean true (for a presence-only flag or an
|
|
35
|
+
* empty-valued query parameter), or an array of strings (when the same
|
|
36
|
+
* key appears more than once). Absent options are not present in the
|
|
37
|
+
* object — 'foo' in ctx.options is the membership test.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Deep-freeze an invocation context so handlers may assume immutability.
|
|
42
|
+
* @param {{ data: Object, args: Object<string,string>, options: Object<string,string|boolean|string[]> }} raw
|
|
43
|
+
* @returns {InvocationContext}
|
|
44
|
+
*/
|
|
45
|
+
export function freezeInvocationContext({ data, args, options }) {
|
|
46
|
+
for (const v of Object.values(options)) {
|
|
47
|
+
if (Array.isArray(v)) Object.freeze(v);
|
|
48
|
+
}
|
|
49
|
+
return Object.freeze({
|
|
50
|
+
data,
|
|
51
|
+
args: Object.freeze({ ...args }),
|
|
52
|
+
options: Object.freeze({ ...options }),
|
|
53
|
+
});
|
|
54
|
+
}
|
package/src/summary.js
CHANGED
|
@@ -1,19 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Numeric severity per syslog ordering. Mirrors the LOG_LEVEL contract used
|
|
3
|
+
* by libtelemetry. Inlined here so libcli stays free of telemetry deps.
|
|
4
|
+
*/
|
|
5
|
+
const LEVELS = { error: 0, warn: 1, info: 2, debug: 3, trace: 3 };
|
|
6
|
+
const DEFAULT_LEVEL = "info";
|
|
7
|
+
|
|
8
|
+
/** Render post-run summary blocks to stdout; successful blocks are suppressed only when LOG_LEVEL=error is explicitly set (default level is "info", which renders all blocks). */
|
|
1
9
|
export class SummaryRenderer {
|
|
2
10
|
#proc;
|
|
11
|
+
#level;
|
|
3
12
|
|
|
13
|
+
/** Initialize the renderer, reading LOG_LEVEL from the environment; defaults to "info" (all blocks rendered) when LOG_LEVEL is absent or unrecognized. */
|
|
4
14
|
constructor({ process }) {
|
|
5
15
|
this.#proc = process;
|
|
16
|
+
const raw = (process.env?.LOG_LEVEL || "").toLowerCase().trim();
|
|
17
|
+
this.#level = LEVELS[raw] ?? LEVELS[DEFAULT_LEVEL];
|
|
6
18
|
}
|
|
7
19
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Whether a block describing a run with the given `ok` would be rendered
|
|
22
|
+
* under the current LOG_LEVEL. Centralizes the suppression rule so callers
|
|
23
|
+
* that need to gate richer output (tables, multi-line blocks) on the same
|
|
24
|
+
* policy don't need to reimplement the level check.
|
|
25
|
+
*
|
|
26
|
+
* @param {boolean} ok
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
shouldRender(ok) {
|
|
30
|
+
if (typeof ok !== "boolean") {
|
|
31
|
+
throw new TypeError(
|
|
32
|
+
"SummaryRenderer.shouldRender requires an explicit `ok` boolean",
|
|
16
33
|
);
|
|
17
34
|
}
|
|
35
|
+
return !(ok && this.#level <= LEVELS.error);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Render a summary block. A block is **atomic, including its top margin**:
|
|
40
|
+
* `render` prepends a single blank line before the title so blocks visually
|
|
41
|
+
* separate from preceding output, and the whole unit (margin + title + items
|
|
42
|
+
* + extras) is suppressed together when LOG_LEVEL=error and the caller
|
|
43
|
+
* reports success (`ok: true`). A failing run still prints so the user sees
|
|
44
|
+
* the diagnostic context regardless of verbosity.
|
|
45
|
+
*
|
|
46
|
+
* Because the margin is owned by the block, callers MUST NOT print their own
|
|
47
|
+
* `\n` before `render`. Doing so leaks a stray blank line when the block is
|
|
48
|
+
* suppressed, and double-spaces it when the block renders.
|
|
49
|
+
*
|
|
50
|
+
* @param {object} params
|
|
51
|
+
* @param {string} params.title Block title (rendered after the leading blank line).
|
|
52
|
+
* @param {Array<{label: string, description: string}>} params.items Rows.
|
|
53
|
+
* @param {boolean} params.ok Whether the run this summary describes succeeded.
|
|
54
|
+
* @param {string} [params.extras] Free-form content rendered after items. Subject to the same suppression as the rest of the block.
|
|
55
|
+
* @param {{ write: (s: string) => void }} [stream] Defaults to process.stdout.
|
|
56
|
+
*/
|
|
57
|
+
render({ title, items, ok, extras }, stream = this.#proc.stdout) {
|
|
58
|
+
if (!this.shouldRender(ok)) return;
|
|
59
|
+
|
|
60
|
+
stream.write("\n" + title + "\n");
|
|
61
|
+
|
|
62
|
+
if (items && items.length > 0) {
|
|
63
|
+
const maxLabel = Math.max(...items.map((item) => item.label.length));
|
|
64
|
+
for (const item of items) {
|
|
65
|
+
stream.write(
|
|
66
|
+
` ${item.label.padEnd(maxLabel)} — ${item.description}\n`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (extras) {
|
|
72
|
+
stream.write(extras.endsWith("\n") ? extras : extras + "\n");
|
|
73
|
+
}
|
|
18
74
|
}
|
|
19
75
|
}
|