@forwardimpact/libcli 0.1.6 → 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 CHANGED
@@ -1,9 +1,30 @@
1
1
  {
2
2
  "name": "@forwardimpact/libcli",
3
- "version": "0.1.6",
4
- "description": "Shared CLI infrastructure for the Forward Impact monorepo",
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
+ "keywords": [
6
+ "cli",
7
+ "agent",
8
+ "arguments",
9
+ "help",
10
+ "grep"
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
+ },
5
18
  "license": "Apache-2.0",
6
19
  "author": "D. Olsson <hi@senzilla.io>",
20
+ "forwardimpact": {
21
+ "capability": "agent-capability",
22
+ "needs": [
23
+ "Parse CLI args and render help",
24
+ "Render colored tables and JSON output",
25
+ "Surface skill-doc links in CLI --help output"
26
+ ]
27
+ },
7
28
  "type": "module",
8
29
  "main": "./src/index.js",
9
30
  "exports": {
@@ -13,22 +34,17 @@
13
34
  "src/**/*.js",
14
35
  "README.md"
15
36
  ],
16
- "engines": {
17
- "bun": ">=1.2.0",
18
- "node": ">=18.0.0"
19
- },
20
37
  "scripts": {
21
38
  "test": "bun test test/*.test.js"
22
39
  },
23
- "repository": {
24
- "type": "git",
25
- "url": "git+https://github.com/forwardimpact/monorepo.git",
26
- "directory": "libraries/libcli"
40
+ "devDependencies": {
41
+ "@forwardimpact/libharness": "^0.1.14"
42
+ },
43
+ "engines": {
44
+ "bun": ">=1.2.0",
45
+ "node": ">=18.0.0"
27
46
  },
28
47
  "publishConfig": {
29
48
  "access": "public"
30
- },
31
- "devDependencies": {
32
- "@forwardimpact/libharness": "^0.1.14"
33
49
  }
34
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
- if (definition.commands && definition.globalOptions) {
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 usage = asCommand.args
92
- ? `${this.#definition.name} ${bare} ${asCommand.args}`
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
- (c) => c.name.length + (c.args ? c.args.length + 1 : 0),
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 left = cmd.args ? `${cmd.name} ${cmd.args}` : cmd.name;
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 (command.args) header += ` ${command.args}`;
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 (command.args) usage += ` ${command.args}`;
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
@@ -1,4 +1,5 @@
1
1
  export { Cli, createCli } from "./cli.js";
2
+ export { freezeInvocationContext } from "./invocation-context.js";
2
3
  export { HelpRenderer } from "./help.js";
3
4
  export { SummaryRenderer } from "./summary.js";
4
5
  export { colors, supportsColor, colorize } from "./color.js";
@@ -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
- render({ title, items }, stream = this.#proc.stdout) {
9
- stream.write(title + "\n");
10
- if (!items || items.length === 0) return;
11
-
12
- const maxLabel = Math.max(...items.map((item) => item.label.length));
13
- for (const item of items) {
14
- stream.write(
15
- ` ${item.label.padEnd(maxLabel)} \u2014 ${item.description}\n`,
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
  }