@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 CHANGED
@@ -1,6 +1,11 @@
1
1
  # libcli
2
2
 
3
- Shared CLI infrastructure for Forward Impact products.
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.7",
4
- "description": "Agent-friendly CLIs: argument parsing, grep-friendly help output, JSON mode, and skill-doc links surfaced in `--help`.",
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
- "forwardimpact": {
13
- "capability": "agent-capability",
14
- "needs": [
15
- "Parse CLI args and render help",
16
- "Render colored tables and JSON output",
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
- "repository": {
39
- "type": "git",
40
- "url": "git+https://github.com/forwardimpact/monorepo.git",
41
- "directory": "libraries/libcli"
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
- 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
- }
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 usage = asCommand.args
92
- ? `${this.#definition.name} ${bare} ${asCommand.args}`
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
- * Format an error message
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
- * Format a warning message
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
- (c) => c.name.length + (c.args ? c.args.length + 1 : 0),
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 left = cmd.args ? `${cmd.name} ${cmd.args}` : cmd.name;
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 (command.args) header += ` ${command.args}`;
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 (command.args) usage += ` ${command.args}`;
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
@@ -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,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
- 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`,
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
  }