Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions packages/loopover-mcp/lib/cli-error.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/** Shared CLI failure output (#5928): when `--json` is set, emit a parseable `{ ok: false, error }` object on
* stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */
export declare function reportCliFailure(wantsJson: boolean, message: string, exitCode?: number): number;
/** True when argv includes `--json` or `--json=...` (used before a full parse result exists). */
export declare function argsWantJson(args: Array<string | undefined | null>): boolean;
/** Normalize a thrown value to a safe error string for CLI output. */
export declare function describeCliError(error: unknown): string;
27 changes: 10 additions & 17 deletions packages/loopover-mcp/lib/cli-error.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 21 additions & 0 deletions packages/loopover-mcp/lib/cli-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/** Shared CLI failure output (#5928): when `--json` is set, emit a parseable `{ ok: false, error }` object on
* stdout (matching each command's success-path JSON stream); otherwise log plain text to stderr. */

export function reportCliFailure(wantsJson: boolean, message: string, exitCode = 2): number {
if (wantsJson) {
console.log(JSON.stringify({ ok: false, error: message }, null, 2));
} else {
console.error(message);
}
return exitCode;
}

/** True when argv includes `--json` or `--json=...` (used before a full parse result exists). */
export function argsWantJson(args: Array<string | undefined | null>): boolean {
return args.some((arg) => arg === "--json" || arg?.startsWith("--json=") === true);
}

/** Normalize a thrown value to a safe error string for CLI output. */
export function describeCliError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
23 changes: 23 additions & 0 deletions packages/loopover-mcp/lib/format-table.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
export type FormatTableAlign = "left" | "right";
export type FormatTableHeader = string | {
key: string;
label?: string;
align?: FormatTableAlign;
};
export type FormatTableRow = Record<string, unknown> | unknown[];
export type FormatTableInput = FormatTableRow[] | {
headers?: FormatTableHeader[];
rows?: FormatTableRow[];
};
export type FormatTableOptions = {
align?: Record<string, FormatTableAlign | undefined>;
gap?: number;
};
/**
* Render tabular data as an aligned, monospace plain-text table (header row + one line per row).
* Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }`
* headers and object/array rows. `opts.align` maps a column key/label to `"left"`|`"right"`;
* `opts.gap` sets the space count between columns (default 2). Pure — no I/O, no dependencies.
* Returns "" when there are no columns.
*/
export declare function formatTable(input: FormatTableInput, opts?: FormatTableOptions): string;
66 changes: 34 additions & 32 deletions packages/loopover-mcp/lib/format-table.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 90 additions & 0 deletions packages/loopover-mcp/lib/format-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// Pure, dependency-free monospace table renderer shared by the stdio CLI's report-shaped commands
// (#2231). Kept in lib/ (not the bin) so it can be unit-tested in isolation: the bin auto-runs its
// CLI/MCP entrypoint on import, so importable helpers live here instead.

export type FormatTableAlign = "left" | "right";

export type FormatTableHeader =
| string
| {
key: string;
label?: string;
align?: FormatTableAlign;
};

export type FormatTableRow = Record<string, unknown> | unknown[];

export type FormatTableInput =
| FormatTableRow[]
| {
headers?: FormatTableHeader[];
rows?: FormatTableRow[];
};

export type FormatTableOptions = {
align?: Record<string, FormatTableAlign | undefined>;
gap?: number;
};

type NormalizedHeader = { key: string; label: string; align?: FormatTableAlign };

// Normalize either an array of row objects or an explicit { headers, rows } shape into a common
// { headers, rows } form. For an array of objects the column set is the union of keys in first-seen
// order, and each key doubles as its own header label.
function normalizeInput(input: FormatTableInput): { headers: NormalizedHeader[]; rows: FormatTableRow[] } {
if (Array.isArray(input)) {
const keys: string[] = [];
for (const row of input) {
if (!row || Array.isArray(row) || typeof row !== "object") continue;
for (const key of Object.keys(row)) if (!keys.includes(key)) keys.push(key);
}
return { headers: keys.map((key) => ({ key, label: key })), rows: input };
}
const headers = (input.headers ?? []).map((header): NormalizedHeader =>
typeof header === "string"
? { key: header, label: header }
: { key: header.key, label: header.label ?? header.key, ...(header.align !== undefined ? { align: header.align } : {}) },
);
return { headers, rows: input.rows ?? [] };
}

function stringifyCell(value: unknown): string {
return value === undefined || value === null ? "" : String(value);
}

// A row is either an object keyed by column key or a positional array; read the matching cell.
function readCell(row: FormatTableRow, header: NormalizedHeader, columnIndex: number): unknown {
if (Array.isArray(row)) return row[columnIndex];
return row[header.key];
}

function resolveAlign(header: NormalizedHeader, opts: FormatTableOptions): FormatTableAlign {
const fromOpts = opts.align && (opts.align[header.key] ?? opts.align[header.label]);
return header.align ?? fromOpts ?? "left";
}

/**
* Render tabular data as an aligned, monospace plain-text table (header row + one line per row).
* Accepts an array of row objects, or `{ headers, rows }` with string/`{ key, label, align }`
* headers and object/array rows. `opts.align` maps a column key/label to `"left"`|`"right"`;
* `opts.gap` sets the space count between columns (default 2). Pure — no I/O, no dependencies.
* Returns "" when there are no columns.
*/
export function formatTable(input: FormatTableInput, opts: FormatTableOptions = {}): string {
const { headers, rows } = normalizeInput(input);
if (headers.length === 0) return "";
const gap = " ".repeat(Math.max(1, opts.gap ?? 2));
const aligns = headers.map((header) => resolveAlign(header, opts));
// Precompute every cell's text so column widths and the rendered rows read the same strings.
const bodyCells = rows.map((row) => headers.map((header, column) => stringifyCell(readCell(row, header, column))));
const widths = headers.map((header, column) =>
Math.max(header.label.length, ...bodyCells.map((cells) => cells[column]?.length ?? 0), 0),
);
// Trim trailing padding so a left-aligned final column never emits dangling spaces.
const renderRow = (cells: string[]) =>
cells
.map((text, column) => (aligns[column] === "right" ? text.padStart(widths[column] ?? 0) : text.padEnd(widths[column] ?? 0)))
.join(gap)
.replace(/\s+$/, "");
return [renderRow(headers.map((header) => header.label)), ...bodyCells.map(renderRow)].join("\n");
}
Loading
Loading