@forwardimpact/libcli 0.1.7 → 0.1.8
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/package.json +16 -15
- package/src/cli.js +48 -16
- package/src/help.js +15 -6
- package/src/index.js +1 -0
- package/src/invocation-context.js +54 -0
- package/src/summary.js +62 -8
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libcli",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Agent-friendly CLIs: argument parsing, grep-friendly help
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "Agent-friendly CLIs: argument parsing, handler dispatch, grep-friendly help, JSON mode, and skill-doc links in `--help`.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
7
7
|
"agent",
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
"help",
|
|
10
10
|
"grep"
|
|
11
11
|
],
|
|
12
|
+
"homepage": "https://www.forwardimpact.team",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/forwardimpact/monorepo.git",
|
|
16
|
+
"directory": "libraries/libcli"
|
|
17
|
+
},
|
|
18
|
+
"license": "Apache-2.0",
|
|
19
|
+
"author": "D. Olsson <hi@senzilla.io>",
|
|
12
20
|
"forwardimpact": {
|
|
13
21
|
"capability": "agent-capability",
|
|
14
22
|
"needs": [
|
|
@@ -17,8 +25,6 @@
|
|
|
17
25
|
"Surface skill-doc links in CLI --help output"
|
|
18
26
|
]
|
|
19
27
|
},
|
|
20
|
-
"license": "Apache-2.0",
|
|
21
|
-
"author": "D. Olsson <hi@senzilla.io>",
|
|
22
28
|
"type": "module",
|
|
23
29
|
"main": "./src/index.js",
|
|
24
30
|
"exports": {
|
|
@@ -28,22 +34,17 @@
|
|
|
28
34
|
"src/**/*.js",
|
|
29
35
|
"README.md"
|
|
30
36
|
],
|
|
31
|
-
"engines": {
|
|
32
|
-
"bun": ">=1.2.0",
|
|
33
|
-
"node": ">=18.0.0"
|
|
34
|
-
},
|
|
35
37
|
"scripts": {
|
|
36
38
|
"test": "bun test test/*.test.js"
|
|
37
39
|
},
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@forwardimpact/libharness": "^0.1.14"
|
|
42
|
+
},
|
|
43
|
+
"engines": {
|
|
44
|
+
"bun": ">=1.2.0",
|
|
45
|
+
"node": ">=18.0.0"
|
|
42
46
|
},
|
|
43
47
|
"publishConfig": {
|
|
44
48
|
"access": "public"
|
|
45
|
-
},
|
|
46
|
-
"devDependencies": {
|
|
47
|
-
"@forwardimpact/libharness": "^0.1.14"
|
|
48
49
|
}
|
|
49
50
|
}
|
package/src/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { HelpRenderer } from "./help.js";
|
|
3
|
+
import { freezeInvocationContext } from "./invocation-context.js";
|
|
3
4
|
|
|
4
5
|
export class Cli {
|
|
5
6
|
#definition;
|
|
@@ -14,25 +15,28 @@ export class Cli {
|
|
|
14
15
|
`for command-specific options.`,
|
|
15
16
|
);
|
|
16
17
|
}
|
|
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
|
-
}
|
|
18
|
+
Cli.#validateNoCollisions(definition);
|
|
31
19
|
this.#definition = definition;
|
|
32
20
|
this.#proc = process;
|
|
33
21
|
this.#helpRenderer = helpRenderer;
|
|
34
22
|
}
|
|
35
23
|
|
|
24
|
+
static #validateNoCollisions(definition) {
|
|
25
|
+
if (!definition.commands || !definition.globalOptions) return;
|
|
26
|
+
const globalNames = new Set(Object.keys(definition.globalOptions));
|
|
27
|
+
for (const cmd of definition.commands) {
|
|
28
|
+
if (!cmd.options) continue;
|
|
29
|
+
for (const name of Object.keys(cmd.options)) {
|
|
30
|
+
if (globalNames.has(name)) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`${definition.name}: option "${name}" in command ` +
|
|
33
|
+
`"${cmd.name}" collides with a global option`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
36
40
|
get name() {
|
|
37
41
|
return this.#definition.name;
|
|
38
42
|
}
|
|
@@ -88,8 +92,11 @@ export class Cli {
|
|
|
88
92
|
const bare = match[2];
|
|
89
93
|
const asCommand = this.#definition.commands?.find((c) => c.name === bare);
|
|
90
94
|
if (!asCommand) return null;
|
|
91
|
-
const
|
|
92
|
-
?
|
|
95
|
+
const argsStr = Array.isArray(asCommand.args)
|
|
96
|
+
? asCommand.argsUsage
|
|
97
|
+
: asCommand.args;
|
|
98
|
+
const usage = argsStr
|
|
99
|
+
? `${this.#definition.name} ${bare} ${argsStr}`
|
|
93
100
|
: `${this.#definition.name} ${bare}`;
|
|
94
101
|
return new Error(
|
|
95
102
|
`Unknown option "${match[1]}${bare}". "${bare}" is a command, not an option. Usage: ${usage}`,
|
|
@@ -123,6 +130,31 @@ export class Cli {
|
|
|
123
130
|
return null;
|
|
124
131
|
}
|
|
125
132
|
|
|
133
|
+
dispatch(parsed, { data }) {
|
|
134
|
+
const command = this.#findCommand(parsed.positionals);
|
|
135
|
+
if (!command) {
|
|
136
|
+
throw new Error(`${this.#definition.name}: no matching subcommand`);
|
|
137
|
+
}
|
|
138
|
+
if (typeof command.handler !== "function") {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`${this.#definition.name}: subcommand "${command.name}" lacks a handler — ` +
|
|
141
|
+
`dispatch() requires { args: string[], handler: (ctx) => any }`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
const consumed = command.name.split(" ").length;
|
|
145
|
+
const argv = parsed.positionals.slice(consumed);
|
|
146
|
+
const argNames = Array.isArray(command.args) ? command.args : [];
|
|
147
|
+
const args = Object.fromEntries(
|
|
148
|
+
argNames.map((n, i) => [n, argv[i]]).filter(([, v]) => v !== undefined),
|
|
149
|
+
);
|
|
150
|
+
const ctx = freezeInvocationContext({
|
|
151
|
+
data,
|
|
152
|
+
args,
|
|
153
|
+
options: parsed.values,
|
|
154
|
+
});
|
|
155
|
+
return command.handler(ctx);
|
|
156
|
+
}
|
|
157
|
+
|
|
126
158
|
showHelp() {
|
|
127
159
|
this.#helpRenderer.render(this.#definition, this.#proc.stdout);
|
|
128
160
|
}
|
package/src/help.js
CHANGED
|
@@ -32,16 +32,23 @@ export class HelpRenderer {
|
|
|
32
32
|
return [`Usage: ${definition.name} [options]`, ""];
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
#argsDisplay(cmd) {
|
|
36
|
+
if (Array.isArray(cmd.args)) return cmd.argsUsage || "";
|
|
37
|
+
return cmd.args || "";
|
|
38
|
+
}
|
|
39
|
+
|
|
35
40
|
#renderCommands(definition) {
|
|
36
41
|
if (!definition.commands || definition.commands.length === 0) return [];
|
|
37
42
|
const lines = [this.#sectionHeader("Commands:")];
|
|
38
43
|
const maxWidth = Math.max(
|
|
39
|
-
...definition.commands.map(
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
...definition.commands.map((c) => {
|
|
45
|
+
const argsStr = this.#argsDisplay(c);
|
|
46
|
+
return c.name.length + (argsStr ? argsStr.length + 1 : 0);
|
|
47
|
+
}),
|
|
42
48
|
);
|
|
43
49
|
for (const cmd of definition.commands) {
|
|
44
|
-
const
|
|
50
|
+
const argsStr = this.#argsDisplay(cmd);
|
|
51
|
+
const left = argsStr ? `${cmd.name} ${argsStr}` : cmd.name;
|
|
45
52
|
lines.push(` ${left.padEnd(maxWidth)} ${cmd.description}`);
|
|
46
53
|
}
|
|
47
54
|
lines.push("");
|
|
@@ -106,15 +113,16 @@ export class HelpRenderer {
|
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
#renderCommand(definition, stream, command) {
|
|
116
|
+
const argsStr = this.#argsDisplay(command);
|
|
109
117
|
let header = `${definition.name} ${command.name}`;
|
|
110
|
-
if (
|
|
118
|
+
if (argsStr) header += ` ${argsStr}`;
|
|
111
119
|
if (command.description) header += ` \u2014 ${command.description}`;
|
|
112
120
|
const formatted = supportsColor(this.#proc)
|
|
113
121
|
? formatHeader(header, this.#proc)
|
|
114
122
|
: header;
|
|
115
123
|
|
|
116
124
|
let usage = `Usage: ${definition.name} ${command.name}`;
|
|
117
|
-
if (
|
|
125
|
+
if (argsStr) usage += ` ${argsStr}`;
|
|
118
126
|
usage += " [options]";
|
|
119
127
|
|
|
120
128
|
const globalWithoutVersion = definition.globalOptions
|
|
@@ -173,6 +181,7 @@ export class HelpRenderer {
|
|
|
173
181
|
options: command.options,
|
|
174
182
|
globalOptions: globalWithoutVersion,
|
|
175
183
|
examples: command.examples,
|
|
184
|
+
documentation: definition.documentation,
|
|
176
185
|
};
|
|
177
186
|
out.write(JSON.stringify(obj, null, 2) + "\n");
|
|
178
187
|
} 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,73 @@
|
|
|
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
|
+
|
|
1
8
|
export class SummaryRenderer {
|
|
2
9
|
#proc;
|
|
10
|
+
#level;
|
|
3
11
|
|
|
4
12
|
constructor({ process }) {
|
|
5
13
|
this.#proc = process;
|
|
14
|
+
const raw = (process.env?.LOG_LEVEL || "").toLowerCase().trim();
|
|
15
|
+
this.#level = LEVELS[raw] ?? LEVELS[DEFAULT_LEVEL];
|
|
6
16
|
}
|
|
7
17
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Whether a block describing a run with the given `ok` would be rendered
|
|
20
|
+
* under the current LOG_LEVEL. Centralizes the suppression rule so callers
|
|
21
|
+
* that need to gate richer output (tables, multi-line blocks) on the same
|
|
22
|
+
* policy don't need to reimplement the level check.
|
|
23
|
+
*
|
|
24
|
+
* @param {boolean} ok
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
shouldRender(ok) {
|
|
28
|
+
if (typeof ok !== "boolean") {
|
|
29
|
+
throw new TypeError(
|
|
30
|
+
"SummaryRenderer.shouldRender requires an explicit `ok` boolean",
|
|
16
31
|
);
|
|
17
32
|
}
|
|
33
|
+
return !(ok && this.#level <= LEVELS.error);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Render a summary block. A block is **atomic, including its top margin**:
|
|
38
|
+
* `render` prepends a single blank line before the title so blocks visually
|
|
39
|
+
* separate from preceding output, and the whole unit (margin + title + items
|
|
40
|
+
* + extras) is suppressed together when LOG_LEVEL=error and the caller
|
|
41
|
+
* reports success (`ok: true`). A failing run still prints so the user sees
|
|
42
|
+
* the diagnostic context regardless of verbosity.
|
|
43
|
+
*
|
|
44
|
+
* Because the margin is owned by the block, callers MUST NOT print their own
|
|
45
|
+
* `\n` before `render`. Doing so leaks a stray blank line when the block is
|
|
46
|
+
* suppressed, and double-spaces it when the block renders.
|
|
47
|
+
*
|
|
48
|
+
* @param {object} params
|
|
49
|
+
* @param {string} params.title Block title (rendered after the leading blank line).
|
|
50
|
+
* @param {Array<{label: string, description: string}>} params.items Rows.
|
|
51
|
+
* @param {boolean} params.ok Whether the run this summary describes succeeded.
|
|
52
|
+
* @param {string} [params.extras] Free-form content rendered after items. Subject to the same suppression as the rest of the block.
|
|
53
|
+
* @param {{ write: (s: string) => void }} [stream] Defaults to process.stdout.
|
|
54
|
+
*/
|
|
55
|
+
render({ title, items, ok, extras }, stream = this.#proc.stdout) {
|
|
56
|
+
if (!this.shouldRender(ok)) return;
|
|
57
|
+
|
|
58
|
+
stream.write("\n" + title + "\n");
|
|
59
|
+
|
|
60
|
+
if (items && items.length > 0) {
|
|
61
|
+
const maxLabel = Math.max(...items.map((item) => item.label.length));
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
stream.write(
|
|
64
|
+
` ${item.label.padEnd(maxLabel)} — ${item.description}\n`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (extras) {
|
|
70
|
+
stream.write(extras.endsWith("\n") ? extras : extras + "\n");
|
|
71
|
+
}
|
|
18
72
|
}
|
|
19
73
|
}
|