@lewishowles/cli-style 0.2.2 → 0.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0 - 2026-06-29
4
+
5
+ ### Added
6
+
7
+ - Added `createReporter()` for grouped setup and diagnostics output.
8
+ - Added `severityOrder` and `getHighestSeverityResult()` for group result summaries.
9
+ - Added shared `normaliseWidth()` helpers for width-aware renderers.
10
+ - Added reporter output to the style gallery.
11
+
12
+ ### Changed
13
+
14
+ - Darkened the muted colour token so unchanged rows are easier to distinguish from normal foreground text.
15
+
3
16
  ## 0.2.2 - 2026-06-29
4
17
 
5
18
  ### Added
package/README.md CHANGED
@@ -41,6 +41,43 @@ ui.print(output);
41
41
 
42
42
  Renderer methods return strings. `ui.print()`, `ui.write()`, and CLI commands handle stdout and stderr.
43
43
 
44
+ ## Reporter
45
+
46
+ Use a reporter when a script needs grouped output rather than one row per file.
47
+
48
+ ```js
49
+ import { createReporter } from "@lewishowles/cli-style";
50
+
51
+ const reporter = createReporter({
52
+ colour: false,
53
+ });
54
+
55
+ reporter.section("Setting up Claude + Codex", "(project)");
56
+ reporter.group("Agent scripts", [
57
+ {
58
+ label: "project-diagnostics.py",
59
+ result: "unchanged",
60
+ },
61
+ {
62
+ label: "repo-context.py",
63
+ result: "unchanged",
64
+ },
65
+ ], {
66
+ summary: "2 already linked",
67
+ });
68
+ reporter.status("success", "Done.");
69
+
70
+ console.log(reporter.render());
71
+ ```
72
+
73
+ Groups can include verbose detail rows when a caller opts in:
74
+
75
+ ```js
76
+ const reporter = createReporter({
77
+ verbose: process.env.AGENTS_VERBOSE === "1",
78
+ });
79
+ ```
80
+
44
81
  ## CLI
45
82
 
46
83
  Use `cli-style render <renderer>` when a script needs rendered text from JSON input.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lewishowles/cli-style",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Shared terminal output styles for Lewis Howles tools, diagnostics, and package CLIs.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,7 @@
1
1
  import { resolveProfile } from "./capability/resolve-profile.js";
2
2
  import { resolveTerminalCapabilities } from "./capability/terminal.js";
3
3
  import { profiles } from "./profiles/profiles.js";
4
+ import { createReporter } from "./reporters/create-reporter.js";
4
5
 
5
6
  /**
6
7
  * Create a CLI style renderer with stable, testable defaults.
@@ -27,6 +28,10 @@ export function createCliStyle(options = {}) {
27
28
  print: (value) => {
28
29
  console.log(value);
29
30
  },
31
+ reporter: (reporterOptions = {}) => createReporter({
32
+ ...resolvedOptions,
33
+ ...reporterOptions,
34
+ }),
30
35
  write: (value) => {
31
36
  process.stdout.write(value);
32
37
  },
@@ -0,0 +1,92 @@
1
+ // Default terminal width used when no usable width is provided.
2
+ export const defaultWidth = 80;
3
+
4
+ // Narrowest width that still leaves room for meaningful terminal output.
5
+ export const minimumWidth = 20;
6
+
7
+ /**
8
+ * Resolve a usable terminal width.
9
+ *
10
+ * @param {*} width
11
+ * Caller-provided width.
12
+ * @param {object} options
13
+ * Width bounds.
14
+ * @param {number} options.defaultWidth
15
+ * Width to use when the input is unusable.
16
+ * @param {number} options.maxWidth
17
+ * Optional maximum width.
18
+ * @param {number} options.minWidth
19
+ * Minimum accepted width.
20
+ * @returns {number}
21
+ * Integer width within the requested bounds.
22
+ */
23
+ export function normaliseWidth(width, options = {}) {
24
+ const fallback = normaliseBound(options.defaultWidth, defaultWidth);
25
+ const lowerBound = normaliseBound(options.minWidth, minimumWidth);
26
+ const upperBound = normaliseOptionalBound(options.maxWidth);
27
+ const numericWidth = Number(width);
28
+
29
+ if (width === null || width === undefined || !Number.isFinite(numericWidth)) {
30
+ return clampWidth(fallback, lowerBound, upperBound);
31
+ }
32
+
33
+ return clampWidth(Math.floor(numericWidth), lowerBound, upperBound);
34
+ }
35
+
36
+ /**
37
+ * Resolve a positive numeric bound.
38
+ *
39
+ * @param {*} value
40
+ * Caller-provided bound.
41
+ * @param {number} fallback
42
+ * Bound to use when value is unusable.
43
+ * @returns {number}
44
+ * Positive integer bound.
45
+ */
46
+ function normaliseBound(value, fallback) {
47
+ const numericValue = Number(value);
48
+
49
+ if (!Number.isFinite(numericValue) || numericValue < 1) {
50
+ return fallback;
51
+ }
52
+
53
+ return Math.floor(numericValue);
54
+ }
55
+
56
+ /**
57
+ * Resolve an optional maximum width.
58
+ *
59
+ * @param {*} value
60
+ * Caller-provided maximum width.
61
+ * @returns {number|null}
62
+ * Positive integer maximum or null.
63
+ */
64
+ function normaliseOptionalBound(value) {
65
+ const numericValue = Number(value);
66
+
67
+ if (!Number.isFinite(numericValue) || numericValue < 1) {
68
+ return null;
69
+ }
70
+
71
+ return Math.floor(numericValue);
72
+ }
73
+
74
+ /**
75
+ * Clamp a width between configured bounds.
76
+ *
77
+ * @param {number} width
78
+ * Width to clamp.
79
+ * @param {number} minWidth
80
+ * Lower bound.
81
+ * @param {number|null} maxWidth
82
+ * Optional upper bound.
83
+ * @returns {number}
84
+ * Clamped width.
85
+ */
86
+ function clampWidth(width, minWidth, maxWidth) {
87
+ const lowerClampedWidth = Math.max(width, minWidth);
88
+
89
+ return maxWidth === null
90
+ ? lowerClampedWidth
91
+ : Math.min(lowerClampedWidth, Math.max(maxWidth, minWidth));
92
+ }
@@ -17,6 +17,7 @@ import { row } from "../primitives/row.js";
17
17
  import { status } from "../primitives/status.js";
18
18
  import { step, stepProgress, stepStates } from "../primitives/step-progress.js";
19
19
  import { table } from "../primitives/table.js";
20
+ import { createReporter } from "../reporters/create-reporter.js";
20
21
  import { resultTypes } from "../theme/results.js";
21
22
 
22
23
  // Chip tone examples should expose every configured pill colour.
@@ -128,6 +129,7 @@ export const gallerySections = [
128
129
  // Pattern fixtures available for focused output and interactive search.
129
130
  export const galleryFixtures = [
130
131
  "diagnostic-report",
132
+ "reporter",
131
133
  "command-result",
132
134
  "task-summary",
133
135
  "agent-transcript",
@@ -435,6 +437,64 @@ function renderPatterns(options, fixture) {
435
437
  ],
436
438
  title: "Project diagnostics",
437
439
  }, options);
440
+ const reporter = createReporter(options);
441
+
442
+ reporter.section("Setting up Claude + Codex", "(project)");
443
+ reporter.group("Project files", [
444
+ {
445
+ detail: "already exists",
446
+ label: "AGENTS.md",
447
+ result: resultTypes.UNCHANGED,
448
+ },
449
+ {
450
+ detail: "already exists",
451
+ label: "WORKSPACE.md",
452
+ result: resultTypes.UNCHANGED,
453
+ },
454
+ ], {
455
+ summary: "2 already present",
456
+ });
457
+ reporter.group("Agent scripts", [
458
+ {
459
+ detail: "already linked",
460
+ label: "project-diagnostics.py",
461
+ result: resultTypes.UNCHANGED,
462
+ },
463
+ {
464
+ detail: "already linked",
465
+ label: "generated-file-guard.py",
466
+ result: resultTypes.UNCHANGED,
467
+ },
468
+ {
469
+ detail: "already linked",
470
+ label: "repo-context.py",
471
+ result: resultTypes.UNCHANGED,
472
+ },
473
+ {
474
+ detail: "already linked",
475
+ label: "change-impact.py",
476
+ result: resultTypes.UNCHANGED,
477
+ },
478
+ ], {
479
+ summary: "4 already linked",
480
+ });
481
+ reporter.group("Claude files", [
482
+ {
483
+ detail: "already exists",
484
+ label: ".claude/",
485
+ result: resultTypes.UNCHANGED,
486
+ },
487
+ {
488
+ detail: "already up to date",
489
+ label: ".claude/.claudeignore",
490
+ result: resultTypes.UNCHANGED,
491
+ },
492
+ ], {
493
+ summary: "2 already current",
494
+ });
495
+ reporter.status(resultTypes.SUCCESS, "Done.");
496
+
497
+ const reporterOutput = reporter.render();
438
498
  const nextStep = nextStepBlock({
439
499
  alternatives: [
440
500
  "Review the focused gallery fixture",
@@ -493,6 +553,7 @@ function renderPatterns(options, fixture) {
493
553
  "confirmation-result": ["Confirmation result", confirmation],
494
554
  "diagnostic-report": ["Diagnostic report", report],
495
555
  "next-step-block": ["Next-step block", nextStep],
556
+ "reporter": ["Reporter", reporterOutput],
496
557
  "task-summary": ["Task summary", summary],
497
558
  };
498
559
 
@@ -509,6 +570,9 @@ function renderPatterns(options, fixture) {
509
570
  "Diagnostic report",
510
571
  frameExample(report, options),
511
572
  "",
573
+ "Reporter",
574
+ frameExample(reporterOutput, options),
575
+ "",
512
576
  "Command result",
513
577
  frameExample(command, options),
514
578
  "",
package/src/index.js CHANGED
@@ -23,6 +23,7 @@ export {
23
23
  } from "./gallery/render-gallery.js";
24
24
 
25
25
  export { background, foreground, stripAnsi, style } from "./formatters/ansi.js";
26
+ export { defaultWidth, minimumWidth, normaliseWidth } from "./formatters/width.js";
26
27
  export { agentTranscript } from "./patterns/agent-transcript.js";
27
28
  export { auditFinding } from "./patterns/audit-finding.js";
28
29
  export { commandResult } from "./patterns/command-result.js";
@@ -41,6 +42,12 @@ export { row } from "./primitives/row.js";
41
42
  export { status } from "./primitives/status.js";
42
43
  export { step, stepProgress, stepStates } from "./primitives/step-progress.js";
43
44
  export { table } from "./primitives/table.js";
45
+ export {
46
+ createReporter,
47
+ renderGroup,
48
+ renderReporterStatus,
49
+ renderSection,
50
+ } from "./reporters/create-reporter.js";
44
51
  export {
45
52
  chartColours,
46
53
  chipColours,
@@ -54,4 +61,11 @@ export {
54
61
  terminalColours,
55
62
  toneColours,
56
63
  } from "./theme/colours.js";
57
- export { getResultSymbol, getResultToken, resultTokens, resultTypes } from "./theme/results.js";
64
+ export {
65
+ getHighestSeverityResult,
66
+ getResultSymbol,
67
+ getResultToken,
68
+ resultTokens,
69
+ resultTypes,
70
+ severityOrder,
71
+ } from "./theme/results.js";
@@ -0,0 +1,214 @@
1
+ import { isNonEmptyString } from "../patterns/helpers.js";
2
+ import { status as renderStatus } from "../primitives/status.js";
3
+ import {
4
+ getHighestSeverityResult,
5
+ getResultToken,
6
+ resultTypes,
7
+ severityOrder,
8
+ } from "../theme/results.js";
9
+
10
+ // Reporter output default options.
11
+ const reporterDefaults = {
12
+ colour: false,
13
+ unicode: true,
14
+ verbose: false,
15
+ };
16
+
17
+ /**
18
+ * Create a pure string reporter for grouped CLI output.
19
+ *
20
+ * @param {object} options
21
+ * Rendering and reporter options.
22
+ * @param {boolean} options.verbose
23
+ * Whether groups should include detail rows by default.
24
+ * @returns {object}
25
+ * Reporter helpers.
26
+ */
27
+ export function createReporter(options = {}) {
28
+ const reporterOptions = {
29
+ ...reporterDefaults,
30
+ ...options,
31
+ };
32
+ const lines = [];
33
+
34
+ return {
35
+ group: (label, items = [], groupOptions = {}) => append(lines, renderGroup(label, items, {
36
+ ...reporterOptions,
37
+ ...groupOptions,
38
+ })),
39
+ lines,
40
+ render: () => lines.join("\n"),
41
+ section: (label, detail = "", sectionOptions = {}) => append(lines, renderSection(label, detail, {
42
+ ...reporterOptions,
43
+ ...sectionOptions,
44
+ })),
45
+ status: (resultType, label, detail = "", statusOptions = {}) => append(lines, renderReporterStatus(resultType, label, detail, {
46
+ ...reporterOptions,
47
+ ...statusOptions,
48
+ })),
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Render a reporter group without mutating a reporter instance.
54
+ *
55
+ * @param {string} label
56
+ * Group label.
57
+ * @param {object[]} items
58
+ * Child result rows.
59
+ * @param {object} options
60
+ * Rendering options.
61
+ * @returns {string}
62
+ * Rendered group.
63
+ */
64
+ export function renderGroup(label, items = [], options = {}) {
65
+ const normalisedItems = normaliseItems(items);
66
+ const resultType = options.result ?? getHighestSeverityResult(normalisedItems.map((item) => item.result));
67
+ const summary = options.summary ?? summariseItems(normalisedItems);
68
+ const groupLine = renderReporterStatus(resultType, label, summary, options);
69
+
70
+ if (options.verbose !== true || normalisedItems.length === 0) {
71
+ return groupLine;
72
+ }
73
+
74
+ const detailLines = normalisedItems.map((item) => {
75
+ const line = renderReporterStatus(item.result, item.label, item.detail, options);
76
+
77
+ return ` ${line}`;
78
+ });
79
+
80
+ return [groupLine, ...detailLines].join("\n");
81
+ }
82
+
83
+ /**
84
+ * Render a reporter section heading.
85
+ *
86
+ * @param {string} label
87
+ * Section label.
88
+ * @param {string} detail
89
+ * Optional detail.
90
+ * @param {object} options
91
+ * Rendering options.
92
+ * @returns {string}
93
+ * Rendered section row.
94
+ */
95
+ export function renderSection(label, detail = "", options = {}) {
96
+ return renderReporterStatus(resultTypes.INFO, label, detail, options);
97
+ }
98
+
99
+ /**
100
+ * Render one reporter status row.
101
+ *
102
+ * @param {string} resultType
103
+ * Result type.
104
+ * @param {string} label
105
+ * Row label.
106
+ * @param {string} detail
107
+ * Optional detail.
108
+ * @param {object} options
109
+ * Rendering options.
110
+ * @returns {string}
111
+ * Rendered status row.
112
+ */
113
+ export function renderReporterStatus(resultType, label, detail = "", options = {}) {
114
+ return renderStatus(resultType, detail, {
115
+ ...options,
116
+ label,
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Append a rendered line to the reporter state.
122
+ *
123
+ * @param {string[]} lines
124
+ * Reporter lines.
125
+ * @param {string} value
126
+ * Rendered value.
127
+ * @returns {string}
128
+ * Appended value.
129
+ */
130
+ function append(lines, value) {
131
+ lines.push(value);
132
+
133
+ return value;
134
+ }
135
+
136
+ /**
137
+ * Keep only rows that can render useful output.
138
+ *
139
+ * @param {object[]} items
140
+ * Caller-provided items.
141
+ * @returns {object[]}
142
+ * Normalised items.
143
+ */
144
+ function normaliseItems(items) {
145
+ if (!Array.isArray(items)) {
146
+ return [];
147
+ }
148
+
149
+ return items
150
+ .filter((item) => item !== null && typeof item === "object")
151
+ .map((item) => ({
152
+ detail: isNonEmptyString(item.detail) ? item.detail : "",
153
+ label: isNonEmptyString(item.label) ? item.label : getResultToken(item.result).label,
154
+ result: getResultToken(item.result) === getResultToken(resultTypes.UNKNOWN)
155
+ ? resultTypes.UNKNOWN
156
+ : item.result,
157
+ }));
158
+ }
159
+
160
+ /**
161
+ * Summarise grouped items by result label.
162
+ *
163
+ * @param {object[]} items
164
+ * Normalised group items.
165
+ * @returns {string}
166
+ * Human-readable count summary.
167
+ */
168
+ function summariseItems(items) {
169
+ if (items.length === 0) {
170
+ return "";
171
+ }
172
+
173
+ const counts = new Map();
174
+
175
+ for (const item of items) {
176
+ counts.set(item.result, (counts.get(item.result) ?? 0) + 1);
177
+ }
178
+
179
+ return [...counts.entries()]
180
+ .sort(([left], [right]) => resultPriority(left) - resultPriority(right))
181
+ .map(([resultType, count]) => formatCount(resultType, count))
182
+ .join(", ");
183
+ }
184
+
185
+ /**
186
+ * Format a result count.
187
+ *
188
+ * @param {string} resultType
189
+ * Result type.
190
+ * @param {number} count
191
+ * Result count.
192
+ * @returns {string}
193
+ * Count label.
194
+ */
195
+ function formatCount(resultType, count) {
196
+ const token = getResultToken(resultType);
197
+ const label = token.label.toLowerCase();
198
+
199
+ return `${count} ${label}`;
200
+ }
201
+
202
+ /**
203
+ * Resolve a result's severity index.
204
+ *
205
+ * @param {string} resultType
206
+ * Result type.
207
+ * @returns {number}
208
+ * Priority index.
209
+ */
210
+ function resultPriority(resultType) {
211
+ const highestResult = getHighestSeverityResult([resultType]);
212
+
213
+ return severityOrder.indexOf(highestResult);
214
+ }
@@ -5,7 +5,7 @@ export const colourTokens = {
5
5
  border: "#2a3a42",
6
6
  danger: "#ff7272",
7
7
  info: "#8bbdff",
8
- muted: "#94a3aa",
8
+ muted: "#6f7f87",
9
9
  success: "#8fdf72",
10
10
  surface: "#0f181b",
11
11
  surfaceRaised: "#132024",
@@ -10,6 +10,18 @@ export const resultTypes = {
10
10
  WARNING: "warning",
11
11
  };
12
12
 
13
+ // Highest-priority result first for grouped summaries.
14
+ export const severityOrder = [
15
+ resultTypes.FAILED,
16
+ resultTypes.WARNING,
17
+ resultTypes.PARTIAL,
18
+ resultTypes.UNKNOWN,
19
+ resultTypes.SUCCESS,
20
+ resultTypes.SKIPPED,
21
+ resultTypes.INFO,
22
+ resultTypes.UNCHANGED,
23
+ ];
24
+
13
25
  // Display tokens for result states before colour rendering exists.
14
26
  export const resultTokens = {
15
27
  [resultTypes.FAILED]: {
@@ -98,6 +110,28 @@ export function getResultToken(resultType) {
98
110
  return resultTokens[resultType] ?? resultTokens[resultTypes.UNKNOWN];
99
111
  }
100
112
 
113
+ /**
114
+ * Resolve the highest-priority result from a list.
115
+ *
116
+ * @param {string[]} resultList
117
+ * Result types to compare.
118
+ * @returns {string}
119
+ * Highest-priority result, or unknown when no known result is present.
120
+ */
121
+ export function getHighestSeverityResult(resultList) {
122
+ if (!Array.isArray(resultList) || resultList.length === 0) {
123
+ return resultTypes.UNKNOWN;
124
+ }
125
+
126
+ for (const resultType of severityOrder) {
127
+ if (resultList.includes(resultType)) {
128
+ return resultType;
129
+ }
130
+ }
131
+
132
+ return resultTypes.UNKNOWN;
133
+ }
134
+
101
135
  /**
102
136
  * Resolve the display symbol for a result type.
103
137
  *