@lmctl-ai/lmformat 0.13.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 (5) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -0
  3. package/index.d.ts +67 -0
  4. package/index.js +405 -0
  5. package/package.json +21 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lattice Systems LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,250 @@
1
+ # lmformat
2
+
3
+ Dependency-free Node.js library for CLI output formatting: automatically sized
4
+ tables, compact counts, durations, and timestamps.
5
+ Install with `npm install @lmctl-ai/lmformat`.
6
+
7
+ Pass an array of rows; each row is an array of cells. No column widths are needed.
8
+
9
+ ```js
10
+ const { formatTable, printTable } = require('@lmctl-ai/lmformat');
11
+ // ESM also supports: import { formatTable, printTable } from '@lmctl-ai/lmformat';
12
+
13
+ const rows = [
14
+ ['triage.lmctl', 'Triage', 'done', 5, '25s'],
15
+ ['math.lmctl', 'Lead', 'done', 120, '2s'],
16
+ ];
17
+ printTable(rows, {
18
+ headers: ['team', 'alias', 'status', 'msgs', 'time spent'],
19
+ align: ['left', 'left', 'left', 'right', 'right'],
20
+ });
21
+ ```
22
+
23
+ ```text
24
+ team alias status msgs time spent
25
+ triage.lmctl Triage done 5 25s
26
+ math.lmctl Lead done 120 2s
27
+ ```
28
+
29
+ `formatTable(rows, { headers, align }?)` returns a string without a final newline.
30
+ `printTable(rows, { headers, align, stream }?)` writes it with a final newline to
31
+ `process.stdout` or a supplied writable stream. Empty output writes nothing.
32
+ Headers are optional. You can also include a header as the first row.
33
+ `align` optionally sets each column to `'left'` (default) or `'right'`;
34
+ numeric columns read best right-aligned. Headers follow their column's alignment.
35
+
36
+ ## Measuring and rendering separately
37
+
38
+ `formatTable` measures and renders in one step. To align SEVERAL tables on one
39
+ shared grid — for example per-section tables under standalone headings — split
40
+ the two steps:
41
+
42
+ ```js
43
+ const { measureColumns, renderGrid } = require('@lmctl-ai/lmformat');
44
+
45
+ const running = [['math.lmctl:Lead', 'exec', 'running 16h ago']];
46
+ const idle = [
47
+ ['triage.lmctl:Triage', 'chat', 'ran 24s, idle 5m ago'],
48
+ ['or.lmctl:Lead', 'chat', 'ran 5s, idle 6m ago'],
49
+ ];
50
+
51
+ const widths = measureColumns([running, idle]); // one width set across all grids
52
+ console.log(renderGrid(running, { widths, margin: 2 }));
53
+ console.log(renderGrid(idle, { widths, margin: 2 }));
54
+ ```
55
+
56
+ ```text
57
+ math.lmctl:Lead exec running 16h ago
58
+ triage.lmctl:Triage chat ran 24s, idle 5m ago
59
+ or.lmctl:Lead chat ran 5s, idle 6m ago
60
+ ```
61
+
62
+ `measureColumns(grids, { max }?)` takes an array of grids; a grid is a rows array or
63
+ `{ rows, headers }`. It returns the display width of each column across all
64
+ grids — simply the widest cell, headers included; pass `max` (an array of
65
+ per-column caps, `null`/`undefined` entries uncapped) to bound chosen columns.
66
+ `renderGrid(rows, { widths, headers, align, margin }?)` renders one grid
67
+ with explicit widths; a non-trailing cell wider than its width is truncated
68
+ with an ellipsis (pass `truncate: false` to let it overflow). Trailing cells
69
+ are open-ended by default; `truncateTrailing: true` bounds them as well.
70
+ `margin` (a number of spaces or a string) indents every rendered line, so
71
+ section grids can sit under their headings without caller-side prefixing.
72
+ Omitting `widths` makes `renderGrid` measure its own rows — exactly what
73
+ `formatTable` does; `max` caps that self-measurement (passing both `widths`
74
+ and `max` throws). Measure globally and render per section, or measure per
75
+ section — the caller chooses.
76
+
77
+ All rows are loaded before formatting. Each column takes the maximum cell length
78
+ across headers and data, capped per column by `max` when given; cells are
79
+ right-padded with spaces and columns have a
80
+ two-space gap. Trailing empty cells add no padding. Ragged rows are accepted;
81
+ `null`, `undefined`, and missing cells become empty strings. Other values use
82
+ `String(value)`. Inputs are not modified. Long text is never wrapped; cells
83
+ over their column's width (measured or capped) are truncated with an ellipsis,
84
+ except trailing cells unless `truncateTrailing: true`. `truncate: false`
85
+ disables all truncation.
86
+
87
+ ## Column widths and caps (manual, per call)
88
+
89
+ One giant cell should not stretch a whole column — and no statistics should
90
+ decide what "giant" means. The widest cell is the default, which callers can override.
91
+ When you have inspected real output and know a column should never exceed a
92
+ width, say so: `measureColumns(grids, { max: [26, null, 20] })` caps column 0
93
+ at 26 and column 2 at 20, leaving column 1 uncapped. Caps also work through
94
+ `renderGrid`/`formatTable`/`printTable` when they self-measure. A cap smaller
95
+ than the header caps the header too (you own both); a cap on the trailing
96
+ column takes effect only with `truncateTrailing: true`. When
97
+ rendering, a non-trailing cell wider than its column is truncated to the
98
+ width with a trailing `…`; pass `truncate: false` to let such cells overflow
99
+ instead.
100
+
101
+ To override just one column, measure first and edit that entry. This can either
102
+ narrow or widen the column while leaving the other measurements intact:
103
+
104
+ ```js
105
+ const widths = measureColumns([running, idle]);
106
+ widths[1] = 20;
107
+ console.log(renderGrid(running, { widths }));
108
+ console.log(renderGrid(idle, { widths }));
109
+ ```
110
+
111
+ To bound a long trailing reason, opt into truncating the last populated cell
112
+ of each row. This also applies to headers, ragged rows, and single-column tables:
113
+
114
+ ```js
115
+ const rows = [['math', 'a very long reason']];
116
+ const widths = measureColumns([rows]);
117
+ widths[1] = 10;
118
+ renderGrid(rows, { widths, truncateTrailing: true }); // 'math a very lo…'
119
+ // The same result using automatic measurement with a cap:
120
+ formatTable(rows, { max: [null, 10], truncateTrailing: true });
121
+ ```
122
+
123
+ `truncateTrailing` defaults to `false` and works in `renderGrid`, `formatTable`,
124
+ and `printTable`. It uses the existing column width and `ellipsis` option.
125
+ `truncate: false` takes precedence. Zero or missing explicit widths remain
126
+ unconstrained. Left-aligned trailing cells stay unpadded; right alignment still
127
+ pads on the left. This is single-line truncation, not wrapping within cells.
128
+
129
+ The truncation mark itself is an option: `ellipsis` defaults to `…`; pass
130
+ `'...'` for ASCII-only terminals or `''` for a hard cut. A mark wider than the
131
+ column falls back to `…`. Like the bar glyphs (`fill`/`empty`/`unknown`), every
132
+ baked-in glyph is a caller option whose default keeps existing output unchanged.
133
+
134
+ Cells must be plain single-line text: tabs, newlines, ANSI escape sequences, and
135
+ other terminal control characters are rejected (use `escapeCell` to sanitize
136
+ untrusted text first). Widths are terminal display widths: East Asian
137
+ wide/fullwidth characters and most emoji count double, combining marks and
138
+ zero-width characters count zero (a pragmatic zero-dependency approximation,
139
+ not a full UAX #11 implementation). A terminal
140
+ narrower than the output may wrap lines.
141
+
142
+ Requires Node.js 18 or newer. Run `npm test` for alignment tests, `npm run check`
143
+ for syntax checks, and `npm run example` for the complete session example.
144
+
145
+ ## Relative timestamps
146
+
147
+ ```js
148
+ const { formatRelativeTime, printTable } = require('@lmctl-ai/lmformat');
149
+ const now = '2026-09-25T20:00:00Z'; // Omit to use Date.now().
150
+ printTable([
151
+ ['weekly', formatRelativeTime('2026-09-28T20:00:00Z', { now })],
152
+ ['session', formatRelativeTime('2026-09-25T20:40:00Z', { now })],
153
+ ['missing', formatRelativeTime('unknown', { now })],
154
+ ], { headers: ['limit', 'resets in'] });
155
+ // limit resets in
156
+ // weekly 3d
157
+ // session 40m
158
+ // missing unknown
159
+ ```
160
+
161
+ `formatRelativeTime(timestamp, { now }?)` accepts a Date, an epoch timestamp in
162
+ milliseconds, or a date string accepted by `Date.parse`. Prefer ISO 8601 strings
163
+ with an explicit timezone. It returns the largest whole unit (`w`, `d`, `h`,
164
+ `m`, `s`), rounding down: 2 days and 20 hours becomes `2d`. Days mean 24 hours;
165
+ weeks mean 7 days.
166
+ Future timestamps return `3d`; past timestamps return `3d ago`; differences below
167
+ one second return `now`. Missing or invalid timestamps return `unknown`.
168
+ An invalid explicit `now` throws a TypeError. Capture `now` once when formatting
169
+ multiple timestamps so the report uses a consistent reference time.
170
+
171
+ Run `npm run example:ratelimit` for the supplied provider and rate-limit data,
172
+ including read ages, reset countdowns, credits, and exhausted status. This example
173
+ uses the fixed snapshot time `2026-09-25T20:33:47Z` for reproducible output.
174
+ Transform timestamp cells before passing rows to the table formatter; widths
175
+ are calculated from the resulting text automatically.
176
+
177
+ ## Counts, durations, and clocks
178
+
179
+ ```js
180
+ const {
181
+ formatCount, formatDuration, formatPercent, formatClock, formatLocalTimestamp,
182
+ formatBar, formatTimer,
183
+ } = require('@lmctl-ai/lmformat');
184
+
185
+ formatCount(960_462); // "960K" (>= 1M keeps one decimal: "15.8M")
186
+ formatDuration(7_500_000); // "2h 5m" (up to two units: "3m 12s", "1w 2d")
187
+ formatDuration(788_400_000, { maxUnit: "d" }); // "9d 3h" (cap the largest unit)
188
+ formatDuration(180_180_000, { style: "compact" }); // "2d02h03m" (dense, no seconds)
189
+ formatPercent(0.16); // "16%"
190
+ formatBar(0.16); // "## " (bare fill, no frame)
191
+ formatBar(0.5, { width: 6, fill: '█', empty: '░' }); // "███░░░"
192
+ formatBar(null, { width: 3, unknown: '·' }); // "···"
193
+ formatTimer(2_172_000); // "00:36:12" ("5d 18:24:33" past 24h)
194
+ formatClock(); // "09:05:03" local wall clock, now or given time
195
+ formatLocalTimestamp(); // "2026-01-02 09:05:03" local date and time
196
+ ```
197
+
198
+ `formatCount` accepts a finite number and returns `unknown` otherwise. It rounds
199
+ symmetrically for negative values and promotes units after rounding, so
200
+ `999_999` becomes `"1.0M"` rather than `"1000K"`.
201
+ `formatDuration` takes milliseconds, clamps negatives to `0s`, and returns
202
+ `unknown` for non-finite input. `formatPercent` takes a fraction (`0.16` for 16%)
203
+ and returns `?%` for non-finite input — deliberately not `unknown`, since the
204
+ inline `used ?% remaining ?%` context wants the unit suffix kept. `formatClock` and `formatLocalTimestamp`
205
+ accept a Date, epoch milliseconds, or a parseable date string (default: now),
206
+ and return `unknown` for invalid input.
207
+
208
+ `formatBar` defaults to width 10, fill `#`, empty space, and unknown `?`.
209
+ Each custom glyph must be a single character with terminal display width 1;
210
+ wide emoji/CJK, combining marks, control characters, and multi-character strings
211
+ are rejected. Fractions still round to the nearest filled cell and clamp to
212
+ 0–1. Non-finite input repeats the `unknown` glyph across the bar.
213
+
214
+ MIT licensed. Project homepage: [lmctl.com](https://lmctl.com).
215
+
216
+ ## Publishing
217
+
218
+ CI builds and tests pushes to `main` and pull requests on Node 18, 20, 22, and 24.
219
+ It also installs the built tarball and checks CommonJS and ESM imports.
220
+ `npm run build` checks syntax and produces `release/lmctl-ai-lmformat-VERSION.tgz`.
221
+ Only the library, package metadata, README, and license are included.
222
+
223
+ Publishing uses [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/)
224
+ from GitHub Actions with provenance. No npm token is stored in GitHub.
225
+
226
+ One-time setup by an npm account with access to the `@lmctl-ai` scope:
227
+
228
+ 1. Run `npm login`, then `npm ci && npm test && npm run build` and
229
+ `npm publish ./release/lmctl-ai-lmformat-0.1.0.tgz --access public` to create
230
+ the first package version. Complete npm's authentication/2FA prompt if requested.
231
+ 2. On npmjs.com, open `@lmctl-ai/lmformat` → Settings → Trusted Publisher and
232
+ select GitHub Actions. Set organization to `lmctl-ai`, repository to `lmformat`,
233
+ and workflow filename to `publish.yml`. Leave environment blank and allow
234
+ direct publishing (`npm publish`).
235
+ 3. Subsequent versions publish automatically when their matching version tag is
236
+ pushed. The workflow tests and builds again before publishing with provenance.
237
+
238
+ For example, after committing changes on `main`:
239
+
240
+ ```sh
241
+ npm version patch -m 'Release %s for npm consumers'
242
+ git push origin main
243
+ git push origin --tags
244
+ ```
245
+
246
+ The tag must equal `v` plus the version in `package.json`. A tag for an already
247
+ published version cannot republish it. No version is changed by CI itself.
248
+ For a build and publish dry run, use the Publish workflow's **Run workflow** button
249
+ with `dry_run` checked. To retry an unpublished tagged version, select that tag
250
+ and uncheck `dry_run`. Real manual publishes from branch refs are rejected.
package/index.d.ts ADDED
@@ -0,0 +1,67 @@
1
+ export type TableCell = string | number | boolean | null | undefined;
2
+ export type ColumnAlign = 'left' | 'right';
3
+
4
+ export interface TableOptions {
5
+ headers?: TableCell[];
6
+ align?: ColumnAlign[];
7
+ /** Truncate over-width cells (default true). False disables all truncation. */
8
+ truncate?: boolean;
9
+ /** Also truncate the last populated cell of each row (default false).
10
+ * Uses its column width and ellipsis; requires truncate to be enabled. */
11
+ truncateTrailing?: boolean;
12
+ /** Leading indent for every line: a number of spaces or a string. */
13
+ margin?: number | string;
14
+ /** Per-column width caps from manual inspection; null/undefined entries
15
+ * stay uncapped. Over-width non-trailing cells truncate with an ellipsis. */
16
+ max?: Array<number | null | undefined>;
17
+ /** Truncation mark (default '…'); '' gives a hard cut. A mark wider than
18
+ * the column falls back to '…'. */
19
+ ellipsis?: string;
20
+ }
21
+
22
+ export interface Grid {
23
+ rows: TableCell[][];
24
+ headers?: TableCell[];
25
+ }
26
+
27
+ export interface RenderGridOptions extends TableOptions {
28
+ widths?: number[];
29
+ }
30
+
31
+ export interface MeasureOptions {
32
+ /** Per-column width caps from manual inspection; null/undefined entries
33
+ * stay uncapped. */
34
+ max?: Array<number | null | undefined>;
35
+ }
36
+
37
+ export interface PrintTableOptions extends TableOptions {
38
+ stream?: { write(text: string): void };
39
+ }
40
+
41
+ export function measureColumns(grids: Array<Grid | TableCell[][]>, options?: MeasureOptions): number[];
42
+ export function renderGrid(rows: TableCell[][], options?: RenderGridOptions): string;
43
+ export function formatTable(rows: TableCell[][], options?: TableOptions): string;
44
+ export function printTable(rows: TableCell[][], options?: PrintTableOptions): void;
45
+
46
+ export type TimestampInput = Date | number | string;
47
+
48
+ export function formatRelativeTime(value: TimestampInput, options?: { now?: TimestampInput }): string;
49
+ export function formatCount(value: number | null | undefined): string;
50
+ export type DurationUnit = 'w' | 'd' | 'h' | 'm' | 's';
51
+
52
+ export function formatDuration(milliseconds: number | null | undefined, options?: { maxUnit?: DurationUnit; style?: 'spaced' | 'compact' }): string;
53
+ export function formatClock(value?: TimestampInput): string;
54
+ export function formatLocalTimestamp(value?: TimestampInput): string;
55
+ export function formatPercent(fraction: number | null | undefined): string;
56
+ export interface BarOptions {
57
+ width?: number;
58
+ /** Single character of display width 1 (default '#'). */
59
+ fill?: string;
60
+ /** Single character of display width 1 (default ' '). */
61
+ empty?: string;
62
+ /** Single character of display width 1 for non-finite input (default '?'). */
63
+ unknown?: string;
64
+ }
65
+ export function formatBar(fraction: number | null | undefined, options?: BarOptions): string;
66
+ export function formatTimer(milliseconds: number | null | undefined): string;
67
+ export function escapeCell(value: unknown): string;
package/index.js ADDED
@@ -0,0 +1,405 @@
1
+ 'use strict';
2
+
3
+ function normalizeRow(row) {
4
+ if (!Array.isArray(row)) {
5
+ throw new TypeError('Each row and headers must be an array of cells');
6
+ }
7
+ return Array.from(row, (value) => {
8
+ const cell = value == null ? '' : String(value);
9
+ if (/[\x00-\x1f\x7f-\x9f]/u.test(cell)) {
10
+ throw new TypeError('Cells must be single-line text without terminal control characters');
11
+ }
12
+ return cell;
13
+ });
14
+ }
15
+
16
+ function normalizeAlign(align) {
17
+ if (align === undefined) return [];
18
+ if (!Array.isArray(align)) {
19
+ throw new TypeError('align must be an array of "left" or "right" per column');
20
+ }
21
+ return align.map((value) => {
22
+ if (value !== 'left' && value !== 'right') {
23
+ throw new TypeError('align entries must be "left" or "right"');
24
+ }
25
+ return value;
26
+ });
27
+ }
28
+
29
+ /** Approximate terminal display width (zero-dependency wcwidth): combining
30
+ * marks and zero-width characters add nothing; East Asian wide/fullwidth
31
+ * code points and most emoji count double. Close enough for column
32
+ * alignment; not a full UAX #11 implementation. */
33
+ function displayWidth(text) {
34
+ let width = 0;
35
+ for (const char of text) {
36
+ const cp = char.codePointAt(0);
37
+ if (
38
+ (cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
39
+ (cp >= 0x1ab0 && cp <= 0x1aff) || // combining diacriticals extended
40
+ (cp >= 0x1dc0 && cp <= 0x1dff) || // combining diacriticals supplement
41
+ (cp >= 0x20d0 && cp <= 0x20ff) || // combining marks for symbols
42
+ (cp >= 0xfe00 && cp <= 0xfe0f) || // variation selectors
43
+ (cp >= 0xfe20 && cp <= 0xfe2f) || // combining half marks
44
+ cp === 0x200b || // zero-width space
45
+ (cp >= 0xe0100 && cp <= 0xe01ef) // variation selectors supplement
46
+ ) continue;
47
+ if (
48
+ (cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
49
+ (cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi, ideographic
50
+ (cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK compatibility
51
+ (cp >= 0x3400 && cp <= 0x4dbf) || // CJK extension A
52
+ (cp >= 0x4e00 && cp <= 0x9fff) || // CJK unified ideographs
53
+ (cp >= 0xa000 && cp <= 0xa4cf) || // Yi
54
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
55
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
56
+ (cp >= 0xfe30 && cp <= 0xfe6f) || // CJK compatibility forms
57
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
58
+ (cp >= 0xffe0 && cp <= 0xffe6) || // fullwidth signs
59
+ (cp >= 0x2600 && cp <= 0x27bf) || // misc symbols + dingbats
60
+ (cp >= 0x1f000 && cp <= 0x1faff) || // emoji (incl. transport, supplemental)
61
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK extensions B+
62
+ ) {
63
+ width += 2;
64
+ continue;
65
+ }
66
+ width += 1;
67
+ }
68
+ return width;
69
+ }
70
+
71
+ function padDisplay(cell, width, right) {
72
+ const padding = ' '.repeat(Math.max(0, width - displayWidth(cell)));
73
+ return right ? padding + cell : cell + padding;
74
+ }
75
+
76
+ function normalizeGrid(grid) {
77
+ if (Array.isArray(grid)) return { rows: grid };
78
+ if (grid !== null && typeof grid === 'object' && Array.isArray(grid.rows)) return grid;
79
+ throw new TypeError('each grid must be a rows array or { rows, headers }');
80
+ }
81
+
82
+ function normalizeEllipsis(ellipsis) {
83
+ if (ellipsis === undefined) return '…';
84
+ if (typeof ellipsis !== 'string' || /[\x00-\x1f\x7f-\x9f]/u.test(ellipsis)) {
85
+ throw new TypeError('ellipsis must be a single-line string without terminal control characters');
86
+ }
87
+ return ellipsis;
88
+ }
89
+
90
+ /** Truncate a cell to a display width, ending with an ellipsis. A custom
91
+ * `ellipsis` (including '' for a hard cut) replaces the default '…'; one
92
+ * wider than the column falls back to '…'. */
93
+ function truncateDisplay(cell, width, ellipsis = '…') {
94
+ if (displayWidth(cell) <= width) return cell;
95
+ const mark = displayWidth(ellipsis) > width ? '…' : ellipsis;
96
+ const markWidth = displayWidth(mark);
97
+ if (width <= markWidth) return mark;
98
+ let out = '';
99
+ let used = 0;
100
+ for (const char of cell) {
101
+ const charWidth = displayWidth(char);
102
+ if (used + charWidth > width - markWidth) break;
103
+ out += char;
104
+ used += charWidth;
105
+ }
106
+ return `${out}${mark}`;
107
+ }
108
+
109
+ function normalizeMax(max) {
110
+ if (max === undefined) return [];
111
+ if (!Array.isArray(max)) {
112
+ throw new TypeError('max must be an array of per-column width caps');
113
+ }
114
+ return max.map((value) => {
115
+ if (value === null || value === undefined) return undefined;
116
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
117
+ throw new TypeError('max entries must be positive integers, null, or undefined');
118
+ }
119
+ return value;
120
+ });
121
+ }
122
+
123
+ /** Measure column display widths across one or more grids. Pair with
124
+ * renderGrid to align several tables on one shared grid.
125
+ * Each column's width is simply the widest cell (headers included) — no
126
+ * statistical trimming. When real data shows a column needs a bound, pass
127
+ * { max: [cap, ...] } with a manually chosen cap per column (null/undefined
128
+ * entries stay uncapped); over-width non-trailing cells then truncate with
129
+ * an ellipsis at render time. A cap on the trailing column has no visible
130
+ * effect by default: pass truncateTrailing at render time to bound it too. */
131
+ function measureColumns(grids, { max } = {}) {
132
+ if (!Array.isArray(grids)) {
133
+ throw new TypeError('grids must be an array of grids');
134
+ }
135
+ const caps = normalizeMax(max);
136
+ const widths = [];
137
+ const push = (column, width) => {
138
+ widths[column] = Math.max(widths[column] ?? 0, width);
139
+ };
140
+ for (const input of grids) {
141
+ const grid = normalizeGrid(input);
142
+ if (grid.headers !== undefined) {
143
+ normalizeRow(grid.headers).forEach((cell, column) => {
144
+ push(column, displayWidth(cell));
145
+ });
146
+ }
147
+ for (const row of grid.rows.map(normalizeRow)) {
148
+ row.forEach((cell, column) => {
149
+ push(column, displayWidth(cell));
150
+ });
151
+ }
152
+ }
153
+ return widths.map((width, column) => {
154
+ const cap = caps[column];
155
+ return cap === undefined ? width : Math.min(width, cap);
156
+ });
157
+ }
158
+
159
+ exports.measureColumns = measureColumns;
160
+
161
+ function normalizeMargin(margin) {
162
+ if (margin === undefined) return '';
163
+ if (typeof margin === 'number' && Number.isInteger(margin) && margin >= 0) return ' '.repeat(margin);
164
+ if (typeof margin === 'string' && !/[\x00-\x1f\x7f-\x9f]/u.test(margin)) return margin;
165
+ throw new TypeError('margin must be a number of spaces or a single-line string');
166
+ }
167
+
168
+ /** Render rows with explicit column widths (from measureColumns). Non-trailing
169
+ * cells wider than their column are truncated with an ellipsis so the grid
170
+ * stays aligned (pass { truncate: false } to let them overflow); trailing
171
+ * cells render in full by default. Set truncateTrailing to also truncate the
172
+ * last populated cell of each row; left-aligned trailing cells stay unpadded.
173
+ * Omitted widths measure the given rows alone (equivalent to
174
+ * formatTable); { max } caps that self-measurement per column (it is an
175
+ * error to pass both widths and max). `margin` (spaces count or string)
176
+ * indents every line; `ellipsis` (default '…') is the truncation mark —
177
+ * '' gives a hard cut. */
178
+ function renderGrid(rows, { widths, headers, align, truncate = true, truncateTrailing = false, margin, max, ellipsis } = {}) {
179
+ if (!Array.isArray(rows)) {
180
+ throw new TypeError('rows must be an array of row arrays');
181
+ }
182
+ if (widths !== undefined && !Array.isArray(widths)) {
183
+ throw new TypeError('widths must be an array of column widths');
184
+ }
185
+ if (widths !== undefined && max !== undefined) {
186
+ throw new TypeError('pass widths or max, not both (max caps a measurement)');
187
+ }
188
+ const indent = normalizeMargin(margin);
189
+ const alignment = normalizeAlign(align);
190
+ const mark = normalizeEllipsis(ellipsis);
191
+ const columns = widths ?? measureColumns([{ rows, headers }], { max });
192
+ const table = rows.map(normalizeRow);
193
+ if (headers !== undefined) table.unshift(normalizeRow(headers));
194
+
195
+ const body = table.map((row) => {
196
+ // Omit absent trailing cells, but preserve padding before later populated cells.
197
+ let last = row.length - 1;
198
+ while (last >= 0 && row[last] === '') last--;
199
+ return row.slice(0, last + 1).map((cell, column) => {
200
+ const width = columns[column] || 0;
201
+ const fitted = truncate && width > 0 && (column !== last || truncateTrailing)
202
+ ? truncateDisplay(cell, width, mark) : cell;
203
+ if (alignment[column] === 'right') return padDisplay(fitted, width, true);
204
+ return column === last ? fitted : padDisplay(fitted, width, false);
205
+ }).join(' ');
206
+ }).join('\n');
207
+ if (body === '' || indent === '') return body;
208
+ return `${indent}${body.split('\n').join(`\n${indent}`)}`;
209
+ }
210
+
211
+ exports.renderGrid = renderGrid;
212
+
213
+ /** Format rows using the widest cell in each column, with two spaces between columns. */
214
+ function formatTable(rows, { headers, align, truncate, truncateTrailing, margin, max, ellipsis } = {}) {
215
+ return renderGrid(rows, { headers, align, truncate, truncateTrailing, margin, max, ellipsis });
216
+ }
217
+
218
+ /** Print a formatted table and a final newline; empty output writes nothing. */
219
+ function printTable(rows, { headers, align, truncate, truncateTrailing, margin, max, ellipsis, stream = process.stdout } = {}) {
220
+ const output = formatTable(rows, { headers, align, truncate, truncateTrailing, margin, max, ellipsis });
221
+ if (output) stream.write(`${output}\n`);
222
+ }
223
+
224
+ exports.formatTable = formatTable;
225
+ exports.printTable = printTable;
226
+
227
+ function timestamp(value) {
228
+ if (value instanceof Date) return value.getTime();
229
+ if (typeof value === 'number') return value;
230
+ if (typeof value === 'string' && value.trim()) return Date.parse(value);
231
+ return NaN;
232
+ }
233
+
234
+ /** Compact time until a timestamp; past times carry an "ago" suffix. */
235
+ function formatRelativeTime(value, { now = Date.now() } = {}) {
236
+ const reference = timestamp(now);
237
+ if (!Number.isFinite(reference)) throw new TypeError('now must be a valid timestamp');
238
+ const target = timestamp(value);
239
+ if (!Number.isFinite(target)) return 'unknown';
240
+
241
+ const difference = target - reference;
242
+ const seconds = Math.floor(Math.abs(difference) / 1000);
243
+ if (seconds === 0) return 'now';
244
+ for (const [unit, size] of [['w', 604800], ['d', 86400], ['h', 3600], ['m', 60], ['s', 1]]) {
245
+ if (seconds >= size) {
246
+ return `${Math.floor(seconds / size)}${unit}${difference < 0 ? ' ago' : ''}`;
247
+ }
248
+ }
249
+ }
250
+
251
+ exports.formatRelativeTime = formatRelativeTime;
252
+
253
+ /** Compact token/byte-style count: 960462 -> "960K", 15791104 -> "15.8M". */
254
+ function formatCount(value) {
255
+ if (typeof value !== 'number' || !Number.isFinite(value)) return 'unknown';
256
+ const sign = value < 0 ? '-' : '';
257
+ const absolute = Math.abs(value);
258
+ if (absolute < 1_000) return String(value);
259
+ const thousands = Math.round(absolute / 1_000);
260
+ // Promote after rounding so 999_999 becomes "1.0M", not "1000K".
261
+ if (thousands < 1_000) return `${sign}${thousands}K`;
262
+ return `${sign}${(absolute / 1_000_000).toFixed(1)}M`;
263
+ }
264
+
265
+ exports.formatCount = formatCount;
266
+
267
+ const DURATION_UNITS = [['w', 604800], ['d', 86400], ['h', 3600], ['m', 60], ['s', 1]];
268
+
269
+ /** Elapsed duration. Default style is up to two spaced units: "45s",
270
+ * "3m 12s", "2h 5m", "1w 2d". `style: 'compact'` renders zero-padded
271
+ * adjacent units down to MINUTES with no seconds: "2d02h03m", "04h59m",
272
+ * "<1m" — dense cells for grids. `maxUnit` ('w' default, or 'd'/'h'/'m'/'s')
273
+ * caps the largest unit — e.g. 'd' renders 9 days as "9d 3h" / "9d03h"
274
+ * instead of "1w 2d". */
275
+ function formatDuration(milliseconds, { maxUnit = 'w', style = 'spaced' } = {}) {
276
+ if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return 'unknown';
277
+ const start = DURATION_UNITS.findIndex(([unit]) => unit === maxUnit);
278
+ if (start === -1) throw new TypeError('maxUnit must be one of "w", "d", "h", "m", "s"');
279
+ if (style !== 'spaced' && style !== 'compact') throw new TypeError('style must be "spaced" or "compact"');
280
+ const secondsTotal = Math.floor(Math.max(0, milliseconds) / 1000);
281
+ if (style === 'compact') {
282
+ const units = DURATION_UNITS.slice(start).filter(([unit]) => unit !== 's');
283
+ if (units.length === 0 || secondsTotal < 60) return '<1m';
284
+ const parts = [];
285
+ let rest = secondsTotal;
286
+ let started = false;
287
+ for (const [unit, size] of units) {
288
+ const amount = Math.floor(rest / size);
289
+ rest -= amount * size;
290
+ if (amount > 0 && !started) {
291
+ const text = unit === 'd' || unit === 'w' ? `${amount}${unit}` : `${pad2(amount)}${unit}`;
292
+ parts.push({ text, zero: false });
293
+ started = true;
294
+ } else if (started) {
295
+ parts.push({ text: `${pad2(amount)}${unit}`, zero: amount === 0 });
296
+ }
297
+ }
298
+ // Positional zeros stay mid-sequence ("2d00h03m"); trailing zeros drop.
299
+ while (parts.length > 0 && parts[parts.length - 1].zero) parts.pop();
300
+ return parts.map((part) => part.text).join('');
301
+ }
302
+ let seconds = secondsTotal;
303
+ const parts = [];
304
+ for (const [unit, size] of DURATION_UNITS.slice(start)) {
305
+ if (seconds >= size || (unit === 's' && parts.length === 0)) {
306
+ const amount = Math.floor(seconds / size);
307
+ seconds -= amount * size;
308
+ parts.push(`${amount}${unit}`);
309
+ if (parts.length === 2) break;
310
+ }
311
+ }
312
+ return parts.join(' ');
313
+ }
314
+
315
+ exports.formatDuration = formatDuration;
316
+
317
+ function localDate(value) {
318
+ if (value === undefined) return new Date();
319
+ if (value instanceof Date) return value;
320
+ if (typeof value === 'number' || typeof value === 'string') return new Date(value);
321
+ return new Date(NaN);
322
+ }
323
+
324
+ function pad2(value) {
325
+ return String(value).padStart(2, '0');
326
+ }
327
+
328
+ /** Local wall clock "HH:MM:SS", for step narration on stderr. */
329
+ function formatClock(value) {
330
+ const date = localDate(value);
331
+ if (Number.isNaN(date.getTime())) return 'unknown';
332
+ return `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`;
333
+ }
334
+
335
+ exports.formatClock = formatClock;
336
+
337
+ /** Local timestamp "YYYY-MM-DD HH:MM:SS", for log-style lines. */
338
+ function formatLocalTimestamp(value) {
339
+ const date = localDate(value);
340
+ if (Number.isNaN(date.getTime())) return 'unknown';
341
+ const day = `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
342
+ return `${day} ${formatClock(date)}`;
343
+ }
344
+
345
+ exports.formatLocalTimestamp = formatLocalTimestamp;
346
+
347
+ /** Percentage from a fraction: 0.16 -> "16%"; non-finite input -> "?%". */
348
+ function formatPercent(fraction) {
349
+ return typeof fraction === 'number' && Number.isFinite(fraction)
350
+ ? `${Math.round(fraction * 100)}%`
351
+ : '?%';
352
+ }
353
+
354
+ exports.formatPercent = formatPercent;
355
+
356
+ /** Progress bar with customizable single-column glyphs; defaults to ASCII.
357
+ * Non-finite input repeats the unknown glyph (default "?"). */
358
+ function formatBar(fraction, { width = 10, fill = '#', empty = ' ', unknown = '?' } = {}) {
359
+ if (typeof width !== 'number' || !Number.isInteger(width) || width < 1) {
360
+ throw new TypeError('width must be a positive integer');
361
+ }
362
+ for (const [name, glyph] of [['fill', fill], ['empty', empty], ['unknown', unknown]]) {
363
+ if (typeof glyph !== 'string' || [...glyph].length !== 1 ||
364
+ /[\x00-\x1f\x7f-\x9f]/u.test(glyph) || displayWidth(glyph) !== 1) {
365
+ throw new TypeError(`${name} must be a single character with terminal display width 1`);
366
+ }
367
+ }
368
+ if (typeof fraction !== 'number' || !Number.isFinite(fraction)) {
369
+ return unknown.repeat(width);
370
+ }
371
+ const filled = Math.min(width, Math.max(0, Math.round(fraction * width)));
372
+ return `${fill.repeat(filled)}${empty.repeat(width - filled)}`;
373
+ }
374
+
375
+ exports.formatBar = formatBar;
376
+
377
+ /** Clock-style remaining time: "00:36:12", days-prefixed past 24h
378
+ * ("5d 18:24:33"). Floors to whole seconds. */
379
+ function formatTimer(milliseconds) {
380
+ if (typeof milliseconds !== 'number' || !Number.isFinite(milliseconds)) return 'unknown';
381
+ let seconds = Math.floor(Math.max(0, milliseconds) / 1000);
382
+ const days = Math.floor(seconds / 86400);
383
+ seconds -= days * 86400;
384
+ const hours = Math.floor(seconds / 3600);
385
+ seconds -= hours * 3600;
386
+ const minutes = Math.floor(seconds / 60);
387
+ seconds -= minutes * 60;
388
+ const clock = `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`;
389
+ return days > 0 ? `${days}d ${clock}` : clock;
390
+ }
391
+
392
+ exports.formatTimer = formatTimer;
393
+
394
+ /** Escape terminal control characters so untrusted text survives formatTable. */
395
+ function escapeCell(value) {
396
+ const text = value == null ? '' : String(value);
397
+ return text.replace(/[\x00-\x1f\x7f-\x9f]/gu, (char) => {
398
+ if (char === '\n') return '\\n';
399
+ if (char === '\t') return '\\t';
400
+ if (char === '\r') return '\\r';
401
+ return `\\x${char.codePointAt(0).toString(16).padStart(2, '0')}`;
402
+ });
403
+ }
404
+
405
+ exports.escapeCell = escapeCell;
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@lmctl-ai/lmformat",
3
+ "version": "0.13.0",
4
+ "description": "Dependency-free CLI output formatting: auto-sized tables, compact counts, durations, and timestamps",
5
+ "main": "index.js",
6
+ "types": "index.d.ts",
7
+ "license": "MIT",
8
+ "author": "Mike Ma <mike.ma@lmctl.com>",
9
+ "homepage": "https://lmctl.com",
10
+ "repository": { "type": "git", "url": "git+https://github.com/lmctl-ai/lmformat.git" },
11
+ "files": ["index.js", "index.d.ts"],
12
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
13
+ "scripts": {
14
+ "build": "npm run check && node scripts/build.js",
15
+ "test": "node --test",
16
+ "check": "node --check index.js && node --check test/table.test.js && node --check test/time.test.js && node --check test/format.test.js && node --check examples/sessions.js && node --check examples/ratelimit.js",
17
+ "example": "node examples/sessions.js",
18
+ "example:ratelimit": "node examples/ratelimit.js"
19
+ },
20
+ "engines": { "node": ">=18" }
21
+ }