@lewishowles/cli-style 0.1.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.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +153 -0
  3. package/adapters/bash/cli-style.sh +50 -0
  4. package/adapters/python/cli_style.py +104 -0
  5. package/bin/cli-style.js +82 -0
  6. package/examples/diagnostic.mjs +30 -0
  7. package/examples/diagnostic.py +38 -0
  8. package/examples/diagnostic.sh +29 -0
  9. package/examples/gallery.mjs +18 -0
  10. package/package.json +42 -0
  11. package/src/capability/resolve-profile.js +69 -0
  12. package/src/capability/terminal.js +105 -0
  13. package/src/cli/gallery-command.js +238 -0
  14. package/src/cli/help.js +35 -0
  15. package/src/cli/render-command.js +283 -0
  16. package/src/create-cli-style.js +53 -0
  17. package/src/formatters/ansi.js +154 -0
  18. package/src/gallery/render-gallery.js +758 -0
  19. package/src/index.js +52 -0
  20. package/src/patterns/agent-transcript.js +180 -0
  21. package/src/patterns/audit-finding.js +107 -0
  22. package/src/patterns/command-result.js +91 -0
  23. package/src/patterns/compact-data-table.js +67 -0
  24. package/src/patterns/confirmation-result.js +88 -0
  25. package/src/patterns/diagnostic-report.js +144 -0
  26. package/src/patterns/helpers.js +106 -0
  27. package/src/patterns/next-step-block.js +118 -0
  28. package/src/patterns/task-summary.js +59 -0
  29. package/src/primitives/bar-chart.js +131 -0
  30. package/src/primitives/chip.js +33 -0
  31. package/src/primitives/divider.js +64 -0
  32. package/src/primitives/feedback.js +83 -0
  33. package/src/primitives/panel.js +100 -0
  34. package/src/primitives/progress-bar.js +121 -0
  35. package/src/primitives/row.js +80 -0
  36. package/src/primitives/status.js +39 -0
  37. package/src/primitives/step-progress.js +184 -0
  38. package/src/primitives/table.js +239 -0
  39. package/src/profiles/profiles.js +24 -0
  40. package/src/theme/colours.js +141 -0
  41. package/src/theme/results.js +98 -0
@@ -0,0 +1,144 @@
1
+ import { profiles } from "../profiles/profiles.js";
2
+ import { status } from "../primitives/status.js";
3
+ import { resultTypes } from "../theme/results.js";
4
+ import {
5
+ formatLabel,
6
+ isNonEmptyString,
7
+ isRecord,
8
+ renderSection,
9
+ renderTitle,
10
+ } from "./helpers.js";
11
+
12
+ // Diagnostic reports use a stable title when the caller does not provide one.
13
+ const defaultTitle = "Diagnostic report";
14
+
15
+ /**
16
+ * Render check summaries, findings, skipped checks, and next actions.
17
+ *
18
+ * @param {object} report
19
+ * Structured diagnostic report.
20
+ * @param {object[]} report.checks
21
+ * Checks with a name, result, and optional detail.
22
+ * @param {object[]} report.findings
23
+ * Findings with a result and message.
24
+ * @param {string[]} report.nextActions
25
+ * Ordered follow-up actions.
26
+ * @param {object[]} report.skippedChecks
27
+ * Skipped checks with a name and optional reason.
28
+ * @param {string} report.title
29
+ * Report title.
30
+ * @param {object} options
31
+ * Rendering options.
32
+ * @param {string} options.profile
33
+ * Active output profile.
34
+ * @returns {object|string}
35
+ * Structured JSON input or rendered diagnostic text.
36
+ */
37
+ export function diagnosticReport(report, options = {}) {
38
+ if (options.profile === profiles.JSON) {
39
+ return report;
40
+ }
41
+
42
+ if (!isRecord(report)) {
43
+ return "";
44
+ }
45
+
46
+ const sections = [
47
+ renderChecks(report.checks, options),
48
+ renderFindings(report.findings, options),
49
+ renderSkippedChecks(report.skippedChecks, options),
50
+ renderNextActions(report.nextActions, options),
51
+ ].filter((section) => section !== "");
52
+ const title = typeof report.title === "string" && report.title !== ""
53
+ ? report.title
54
+ : defaultTitle;
55
+ return [renderTitle(title, options), ...sections].join("\n\n");
56
+ }
57
+
58
+ /**
59
+ * Render completed check summaries.
60
+ *
61
+ * @param {object[]} checks
62
+ * Checks to render.
63
+ * @param {object} options
64
+ * Rendering options.
65
+ * @returns {string}
66
+ * Rendered checks section.
67
+ */
68
+ function renderChecks(checks, options) {
69
+ const validChecks = Array.isArray(checks)
70
+ ? checks.filter((check) => isRecord(check) && isNonEmptyString(check.name))
71
+ : [];
72
+ const lines = validChecks.map((check) => status(
73
+ check.result,
74
+ formatLabel(check.name, check.detail, options),
75
+ options,
76
+ ));
77
+
78
+ return renderSection("Checks", lines, options);
79
+ }
80
+
81
+ /**
82
+ * Render diagnostic findings.
83
+ *
84
+ * @param {object[]} findings
85
+ * Findings to render.
86
+ * @param {object} options
87
+ * Rendering options.
88
+ * @returns {string}
89
+ * Rendered findings section.
90
+ */
91
+ function renderFindings(findings, options) {
92
+ const validFindings = Array.isArray(findings)
93
+ ? findings.filter((finding) => isRecord(finding) && isNonEmptyString(finding.message))
94
+ : [];
95
+ const lines = validFindings.map((finding) => status(
96
+ finding.result,
97
+ finding.message,
98
+ options,
99
+ ));
100
+
101
+ return renderSection("Findings", lines, options);
102
+ }
103
+
104
+ /**
105
+ * Render skipped checks.
106
+ *
107
+ * @param {object[]} skippedChecks
108
+ * Skipped checks to render.
109
+ * @param {object} options
110
+ * Rendering options.
111
+ * @returns {string}
112
+ * Rendered skipped-check section.
113
+ */
114
+ function renderSkippedChecks(skippedChecks, options) {
115
+ const validChecks = Array.isArray(skippedChecks)
116
+ ? skippedChecks.filter((check) => isRecord(check) && isNonEmptyString(check.name))
117
+ : [];
118
+ const lines = validChecks.map((check) => status(
119
+ resultTypes.SKIPPED,
120
+ formatLabel(check.name, check.reason, options),
121
+ options,
122
+ ));
123
+
124
+ return renderSection("Skipped checks", lines, options);
125
+ }
126
+
127
+ /**
128
+ * Render ordered next actions.
129
+ *
130
+ * @param {string[]} nextActions
131
+ * Actions to render.
132
+ * @param {object} options
133
+ * Rendering options.
134
+ * @returns {string}
135
+ * Rendered next-action section.
136
+ */
137
+ function renderNextActions(nextActions, options) {
138
+ const validActions = Array.isArray(nextActions)
139
+ ? nextActions.filter(isNonEmptyString)
140
+ : [];
141
+ const lines = validActions.map((action, index) => `${index + 1}. ${action}`);
142
+
143
+ return renderSection("Next actions", lines, options, false);
144
+ }
@@ -0,0 +1,106 @@
1
+ import { profiles } from "../profiles/profiles.js";
2
+
3
+ /**
4
+ * Join a primary label and optional detail.
5
+ *
6
+ * @param {string} label
7
+ * Primary label.
8
+ * @param {*} detail
9
+ * Optional detail value.
10
+ * @param {object} options
11
+ * Rendering options.
12
+ * @returns {string}
13
+ * Combined detail text.
14
+ */
15
+ export function formatLabel(label, detail, options) {
16
+ if (!isNonEmptyString(detail)) {
17
+ return label;
18
+ }
19
+
20
+ if (!isNonEmptyString(label)) {
21
+ return detail;
22
+ }
23
+
24
+ const separator = options.unicode === false ? " - " : " — ";
25
+
26
+ return `${label}${separator}${detail}`;
27
+ }
28
+
29
+ /**
30
+ * Check whether a value contains usable text.
31
+ *
32
+ * @param {*} value
33
+ * Value to check.
34
+ * @returns {boolean}
35
+ * Whether the value is a non-empty string.
36
+ */
37
+ export function isNonEmptyString(value) {
38
+ return typeof value === "string" && value !== "";
39
+ }
40
+
41
+ /**
42
+ * Check whether a value is a non-array object.
43
+ *
44
+ * @param {*} value
45
+ * Value to check.
46
+ * @returns {boolean}
47
+ * Whether the value is a record.
48
+ */
49
+ export function isRecord(value) {
50
+ return value !== null && typeof value === "object" && !Array.isArray(value);
51
+ }
52
+
53
+ /**
54
+ * Return usable text entries from an uncertain value.
55
+ *
56
+ * @param {*} values
57
+ * Values to normalise.
58
+ * @returns {string[]}
59
+ * Non-empty strings.
60
+ */
61
+ export function normaliseStringList(values) {
62
+ return Array.isArray(values) ? values.filter(isNonEmptyString) : [];
63
+ }
64
+
65
+ /**
66
+ * Render a titled pattern section.
67
+ *
68
+ * @param {string} title
69
+ * Section title.
70
+ * @param {string[]} lines
71
+ * Section content.
72
+ * @param {object} options
73
+ * Rendering options.
74
+ * @param {boolean} listItems
75
+ * Whether agent output should use bullet items.
76
+ * @returns {string}
77
+ * Rendered section.
78
+ */
79
+ export function renderSection(title, lines, options, listItems = true) {
80
+ if (lines.length === 0) {
81
+ return "";
82
+ }
83
+
84
+ const heading = options.profile === profiles.AGENT
85
+ ? `## ${title}`
86
+ : title;
87
+ const content = options.profile === profiles.AGENT && listItems
88
+ ? lines.map((line) => `- ${line}`)
89
+ : lines;
90
+
91
+ return [heading, ...content].join("\n");
92
+ }
93
+
94
+ /**
95
+ * Render a pattern title for the active profile.
96
+ *
97
+ * @param {string} title
98
+ * Pattern title.
99
+ * @param {object} options
100
+ * Rendering options.
101
+ * @returns {string}
102
+ * Rendered title.
103
+ */
104
+ export function renderTitle(title, options) {
105
+ return options.profile === profiles.AGENT ? `# ${title}` : title;
106
+ }
@@ -0,0 +1,118 @@
1
+ import { profiles } from "../profiles/profiles.js";
2
+ import { row } from "../primitives/row.js";
3
+ import {
4
+ isNonEmptyString,
5
+ isRecord,
6
+ normaliseStringList,
7
+ renderSection,
8
+ renderTitle,
9
+ } from "./helpers.js";
10
+
11
+ // Next-step blocks use a stable title when the caller does not provide one.
12
+ const defaultTitle = "Next step";
13
+
14
+ /**
15
+ * Render the first concrete follow-up action and supporting context.
16
+ *
17
+ * @param {object} nextStep
18
+ * Structured next-step block.
19
+ * @param {string[]} nextStep.alternatives
20
+ * Secondary actions.
21
+ * @param {string[]} nextStep.commands
22
+ * Commands needed for the action.
23
+ * @param {string} nextStep.next
24
+ * Primary next action.
25
+ * @param {string} nextStep.reason
26
+ * Why the action is next.
27
+ * @param {string} nextStep.title
28
+ * Pattern title.
29
+ * @param {object} options
30
+ * Rendering options.
31
+ * @returns {object|string}
32
+ * Structured JSON input or rendered next-step block.
33
+ */
34
+ export function nextStepBlock(nextStep, options = {}) {
35
+ if (options.profile === profiles.JSON) {
36
+ return nextStep;
37
+ }
38
+
39
+ if (!isRecord(nextStep)) {
40
+ return "";
41
+ }
42
+
43
+ const title = isNonEmptyString(nextStep.title) ? nextStep.title : defaultTitle;
44
+ const next = renderNext(nextStep.next, options);
45
+ const reason = isNonEmptyString(nextStep.reason)
46
+ ? renderSection("Why", [nextStep.reason], options, false)
47
+ : "";
48
+ const commands = renderCommands(nextStep.commands, options);
49
+ const alternatives = normaliseStringList(nextStep.alternatives)
50
+ .map((alternative) => `- ${alternative}`);
51
+ const sections = [
52
+ next,
53
+ reason,
54
+ commands,
55
+ renderSection("Alternatives", alternatives, options, false),
56
+ ].filter((section) => section !== "");
57
+
58
+ return [renderTitle(title, options), ...sections].join("\n\n");
59
+ }
60
+
61
+ /**
62
+ * Render the primary next action.
63
+ *
64
+ * @param {*} next
65
+ * Primary next action.
66
+ * @param {object} options
67
+ * Rendering options.
68
+ * @returns {string}
69
+ * Rendered next action.
70
+ */
71
+ function renderNext(next, options) {
72
+ if (!isNonEmptyString(next)) {
73
+ return "";
74
+ }
75
+
76
+ if (options.profile === profiles.AGENT) {
77
+ return renderSection("Next", [next], options, false);
78
+ }
79
+
80
+ return row("Next", next, {
81
+ ...options,
82
+ labelWidth: 4,
83
+ });
84
+ }
85
+
86
+ /**
87
+ * Render commands as shell text.
88
+ *
89
+ * @param {*} commands
90
+ * Commands to render.
91
+ * @param {object} options
92
+ * Rendering options.
93
+ * @returns {string}
94
+ * Rendered commands.
95
+ */
96
+ function renderCommands(commands, options) {
97
+ const validCommands = normaliseStringList(commands);
98
+
99
+ if (validCommands.length === 0) {
100
+ return "";
101
+ }
102
+
103
+ if (options.profile === profiles.AGENT) {
104
+ return [
105
+ "## Commands",
106
+ "```sh",
107
+ ...validCommands,
108
+ "```",
109
+ ].join("\n");
110
+ }
111
+
112
+ return renderSection(
113
+ "Commands",
114
+ validCommands.map((command) => `$ ${command}`),
115
+ options,
116
+ false,
117
+ );
118
+ }
@@ -0,0 +1,59 @@
1
+ import { profiles } from "../profiles/profiles.js";
2
+ import { status } from "../primitives/status.js";
3
+ import { resultTypes } from "../theme/results.js";
4
+ import {
5
+ formatLabel,
6
+ isNonEmptyString,
7
+ isRecord,
8
+ normaliseStringList,
9
+ renderSection,
10
+ renderTitle,
11
+ } from "./helpers.js";
12
+
13
+ // Task summaries use a stable title when the caller does not provide one.
14
+ const defaultTitle = "Task summary";
15
+
16
+ /**
17
+ * Render completed and remaining work for one task.
18
+ *
19
+ * @param {object} summary
20
+ * Structured task summary.
21
+ * @param {string[]} summary.completed
22
+ * Completed work.
23
+ * @param {string[]} summary.remaining
24
+ * Remaining work.
25
+ * @param {string} summary.result
26
+ * Semantic result state.
27
+ * @param {string} summary.summary
28
+ * Short supporting summary.
29
+ * @param {string} summary.task
30
+ * Task name.
31
+ * @param {string} summary.title
32
+ * Pattern title.
33
+ * @param {object} options
34
+ * Rendering options.
35
+ * @returns {object|string}
36
+ * Structured JSON input or rendered task summary.
37
+ */
38
+ export function taskSummary(summary, options = {}) {
39
+ if (options.profile === profiles.JSON) {
40
+ return summary;
41
+ }
42
+
43
+ if (!isRecord(summary)) {
44
+ return "";
45
+ }
46
+
47
+ const title = isNonEmptyString(summary.title) ? summary.title : defaultTitle;
48
+ const task = isNonEmptyString(summary.task) ? summary.task : "";
49
+ const detail = formatLabel(task, summary.summary, options);
50
+ const completed = normaliseStringList(summary.completed).map((item) => `- ${item}`);
51
+ const remaining = normaliseStringList(summary.remaining).map((item) => `- ${item}`);
52
+ const sections = [
53
+ status(summary.result ?? resultTypes.UNKNOWN, detail, options),
54
+ renderSection("Completed", completed, options, false),
55
+ renderSection("Remaining", remaining, options, false),
56
+ ].filter((section) => section !== "");
57
+
58
+ return [renderTitle(title, options), ...sections].join("\n\n");
59
+ }
@@ -0,0 +1,131 @@
1
+ import { foreground } from "../formatters/ansi.js";
2
+ import { chartColours } from "../theme/colours.js";
3
+
4
+ // Chart bars use the same compact default width as progress bars.
5
+ const defaultWidth = 20;
6
+
7
+ /**
8
+ * Render multiple labelled values against a shared scale.
9
+ *
10
+ * @param {object} options
11
+ * Rendering options.
12
+ * @param {number} options.barWidth
13
+ * Number of characters used by the largest bar.
14
+ * @param {boolean} options.colour
15
+ * Whether ANSI colour should be applied.
16
+ * @param {object[]} options.rows
17
+ * Labelled numeric values to render.
18
+ * @param {boolean} options.unicode
19
+ * Whether Unicode bar characters should be used.
20
+ * @returns {string}
21
+ * Rendered bar chart.
22
+ */
23
+ export function barChart(options = {}) {
24
+ const rows = normaliseRows(options.rows);
25
+
26
+ if (rows.length === 0) {
27
+ return "";
28
+ }
29
+
30
+ const labelWidth = Math.max(...rows.map((row) => row.label.length));
31
+ const maximum = Math.max(...rows.map((row) => Math.abs(row.value)), 1);
32
+ const width = normaliseWidth(options.barWidth);
33
+
34
+ return rows
35
+ .map((row) => renderRow(row, labelWidth, maximum, width, options))
36
+ .join("\n");
37
+ }
38
+
39
+ /**
40
+ * Return rows with usable labels and values.
41
+ *
42
+ * @param {object[]} rows
43
+ * Requested chart rows.
44
+ * @returns {object[]}
45
+ * Valid chart rows.
46
+ */
47
+ function normaliseRows(rows) {
48
+ if (!Array.isArray(rows)) {
49
+ return [];
50
+ }
51
+
52
+ return rows.filter((row) => (
53
+ row !== null
54
+ && typeof row === "object"
55
+ && typeof row.label === "string"
56
+ && row.label !== ""
57
+ && Number.isFinite(row.value)
58
+ ));
59
+ }
60
+
61
+ /**
62
+ * Return a usable integer bar width.
63
+ *
64
+ * @param {number} width
65
+ * Requested bar width.
66
+ * @returns {number}
67
+ * Positive integer width.
68
+ */
69
+ function normaliseWidth(width) {
70
+ if (!Number.isFinite(width)) {
71
+ return defaultWidth;
72
+ }
73
+
74
+ return Math.max(Math.floor(width), 1);
75
+ }
76
+
77
+ /**
78
+ * Render one aligned chart row.
79
+ *
80
+ * @param {object} row
81
+ * Chart row.
82
+ * @param {number} labelWidth
83
+ * Shared label width.
84
+ * @param {number} maximum
85
+ * Largest absolute chart value.
86
+ * @param {number} width
87
+ * Maximum bar width.
88
+ * @param {object} options
89
+ * Rendering options.
90
+ * @returns {string}
91
+ * Rendered chart row.
92
+ */
93
+ function renderRow(row, labelWidth, maximum, width, options) {
94
+ const barWidth = Math.round((Math.abs(row.value) / maximum) * width);
95
+ const symbol = options.unicode === false ? "#" : "█";
96
+ const bar = symbol.repeat(barWidth).padEnd(width, " ");
97
+ const label = row.label.padEnd(labelWidth, " ");
98
+
99
+ if (options.colour !== true) {
100
+ return `${label} ${bar} ${row.value}`;
101
+ }
102
+
103
+ const renderedLabel = foreground(label, chartColours.label, options);
104
+ const renderedBar = barWidth === 0
105
+ ? ""
106
+ : foreground(bar.slice(0, barWidth), barColour(row), options);
107
+ const barPadding = bar.slice(barWidth);
108
+ const renderedValue = foreground(String(row.value), chartColours.value, options);
109
+
110
+ return `${renderedLabel} ${renderedBar}${barPadding} ${renderedValue}`;
111
+ }
112
+
113
+ /**
114
+ * Return the colour for a chart row.
115
+ *
116
+ * @param {object} row
117
+ * Chart row.
118
+ * @returns {string}
119
+ * Colour token or hex value.
120
+ */
121
+ function barColour(row) {
122
+ if (row.value < 0) {
123
+ return chartColours.barNegative;
124
+ }
125
+
126
+ if (row.tone === "warning") {
127
+ return chartColours.barWarning;
128
+ }
129
+
130
+ return chartColours.barPositive;
131
+ }
@@ -0,0 +1,33 @@
1
+ import { background, foreground, style } from "../formatters/ansi.js";
2
+ import { chipColours } from "../theme/colours.js";
3
+
4
+ // Chip style used when the requested tone is not configured.
5
+ const fallbackTone = "neutral";
6
+
7
+ /**
8
+ * Render a compact label chip.
9
+ *
10
+ * @param {string} label
11
+ * Label text to render.
12
+ * @param {string} tone
13
+ * Chip tone to use.
14
+ * @param {object} options
15
+ * Rendering options.
16
+ * @param {boolean} options.colour
17
+ * Whether ANSI colour should be applied.
18
+ * @returns {string}
19
+ * Rendered chip.
20
+ */
21
+ export function chip(label, tone = fallbackTone, options = {}) {
22
+ const token = chipColours[tone] ?? chipColours[fallbackTone];
23
+
24
+ if (options.colour !== true) {
25
+ return `[${label}]`;
26
+ }
27
+
28
+ const paddedLabel = ` ${label} `;
29
+ const colouredLabel = foreground(paddedLabel, token.foreground, options);
30
+ const boldLabel = style(colouredLabel, "bold", options);
31
+
32
+ return background(boldLabel, token.background, options);
33
+ }
@@ -0,0 +1,64 @@
1
+ import { foreground } from "../formatters/ansi.js";
2
+
3
+ // Dividers use enough width to read as a section break in compact output.
4
+ const defaultWidth = 40;
5
+
6
+ // Hyphen keeps the divider stable in plain and ASCII-only terminals.
7
+ const defaultCharacter = "-";
8
+
9
+ // Divider colour should expose the border token in diagnostic output.
10
+ const defaultColour = "border";
11
+
12
+ /**
13
+ * Render a horizontal divider.
14
+ *
15
+ * @param {object} options
16
+ * Rendering options.
17
+ * @param {string} options.character
18
+ * Character to repeat.
19
+ * @param {boolean} options.colour
20
+ * Whether ANSI colour should be applied.
21
+ * @param {string} options.dividerColour
22
+ * Colour token for the divider.
23
+ * @param {number} options.dividerWidth
24
+ * Divider width.
25
+ * @param {string} options.label
26
+ * Optional label to render before the divider line.
27
+ * @param {string} options.labelColour
28
+ * Colour token for the label.
29
+ * @returns {string}
30
+ * Rendered divider.
31
+ */
32
+ export function divider(options = {}) {
33
+ const character = options.character ?? defaultCharacter;
34
+ const label = options.label ?? "";
35
+ const width = options.dividerWidth ?? defaultWidth;
36
+ const lineWidth = Math.max(width - label.length - labelGap(label), 0);
37
+ const line = character.repeat(lineWidth);
38
+ const output = label === "" ? line : `${label} ${line}`;
39
+
40
+ if (options.colour !== true) {
41
+ return output;
42
+ }
43
+
44
+ if (label === "") {
45
+ return foreground(line, options.dividerColour ?? defaultColour, options);
46
+ }
47
+
48
+ const renderedLabel = foreground(label, options.labelColour ?? "info", options);
49
+ const renderedLine = foreground(line, options.dividerColour ?? defaultColour, options);
50
+
51
+ return `${renderedLabel} ${renderedLine}`;
52
+ }
53
+
54
+ /**
55
+ * Return the label spacing used inside the requested divider width.
56
+ *
57
+ * @param {string} label
58
+ * Divider label.
59
+ * @returns {number}
60
+ * Width reserved for label gap.
61
+ */
62
+ function labelGap(label) {
63
+ return label === "" ? 0 : 1;
64
+ }