Skip to content
Prev Previous commit
Next Next commit
Compact help to show full command and option names
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
  • Loading branch information
Copilot and pelikhan authored Jun 9, 2026
commit fa8aaea56845401bf5f840c456a489da70ed2536
106 changes: 59 additions & 47 deletions actions/setup/js/mcp_cli_bridge.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,10 @@ const KEEPALIVE_PING_INTERVAL_MS = 10000;
const KEEPALIVE_PING_ID_START = 1000;

/** Preferred max lines for generated CLI help output */
const HELP_MAX_LINES = 20;
const COMMAND_DESC_MAX_LEN = 68;
const TOP_HELP_MAX_LINES = 20;
const TOOL_HELP_MAX_LINES = 30;
const TOOL_DESC_MAX_LEN = 90;
const OPTION_DESC_MAX_LEN = 62;
const TOP_HELP_FOOTER_LINES = 2;
const COMPACT_NAME_LINE_TARGET_WIDTH = 110;

// ---------------------------------------------------------------------------
// Audit logging
Expand Down Expand Up @@ -772,22 +771,16 @@ function loadTools(toolsFile) {
function showHelp(serverName, tools) {
const lines = [`Usage: ${serverName} <command> [--param value ...]`, `Tip: ${serverName} <command> --help`, "", `Commands (${tools.length}):`];
if (tools.length > 0) {
const maxCommandLines = Math.max(1, HELP_MAX_LINES - lines.length - TOP_HELP_FOOTER_LINES);
let shownTools = tools.slice(0, maxCommandLines);
if (tools.length > shownTools.length && lines.length + shownTools.length + TOP_HELP_FOOTER_LINES + 1 > HELP_MAX_LINES) {
shownTools = shownTools.slice(0, -1);
}
for (const tool of shownTools) {
lines.push(` ${tool.name} — ${summarizeHelpText(tool.description || "No description", COMMAND_DESC_MAX_LEN)}`);
}
if (tools.length > shownTools.length) {
lines.push(` ... +${tools.length - shownTools.length} more command(s)`);
}
const maxCommandLines = Math.max(1, TOP_HELP_MAX_LINES - lines.length);
lines.push(
...formatCompactNameLines(
tools.map(tool => tool.name),
maxCommandLines
)
);
} else {
lines.push(" (tool list unavailable)");
}
lines.push("");
lines.push(`Run '${serverName} <command> --help' for option details.`);
process.stdout.write(lines.join("\n") + "\n");
}

Expand Down Expand Up @@ -820,43 +813,22 @@ function showToolHelp(serverName, toolName, tools) {
lines.push("");
lines.push(`Options (${Object.keys(props).length}):`);
const optionEntries = Object.entries(props);
const maxOptionLines = Math.max(1, HELP_MAX_LINES - lines.length);
let shownOptions = optionEntries.slice(0, maxOptionLines);
let hasMoreOptions = optionEntries.length > shownOptions.length;
let hasVisibleRequired = shownOptions.some(([key]) => required.has(key));
while (shownOptions.length > 0 && lines.length + shownOptions.length + (hasMoreOptions ? 1 : 0) + (hasVisibleRequired ? 1 : 0) > HELP_MAX_LINES) {
shownOptions = shownOptions.slice(0, -1);
hasMoreOptions = optionEntries.length > shownOptions.length;
hasVisibleRequired = shownOptions.some(([key]) => required.has(key));
}
for (const [key, val] of shownOptions) {
const requiredMark = required.has(key) ? "*" : "";
const description = val.description ? ` - ${summarizeHelpText(val.description, OPTION_DESC_MAX_LEN)}` : "";
lines.push(` --${key} ${getTypeStr(val.type)}${requiredMark}${description}`);
}
if (hasMoreOptions) {
lines.push(` ... +${optionEntries.length - shownOptions.length} more option(s)`);
}
if (hasVisibleRequired) {
const hasRequiredOptions = required.size > 0;
const maxOptionLines = Math.max(1, TOOL_HELP_MAX_LINES - lines.length - (hasRequiredOptions ? 1 : 0));
lines.push(
...formatCompactNameLines(
optionEntries.map(([key]) => `--${key}${required.has(key) ? "*" : ""}`),
maxOptionLines
)
);
if (hasRequiredOptions) {
lines.push("Required options are marked with *.");
}
Comment on lines +815 to 826
}

process.stdout.write(lines.join("\n") + "\n");
}

/**
* Format a JSON schema type value as a short bracketed string.
*
* @param {string|string[]|undefined} type
* @returns {string}
*/
function getTypeStr(type) {
if (!type) return "(string)";
const types = Array.isArray(type) ? type.filter(t => t !== "null") : [type];
return `(${types.length > 0 ? types.join("|") : "null"})`;
}

/**
* Collapse whitespace and trim long help text for compact output.
*
Expand All @@ -877,6 +849,46 @@ function summarizeHelpText(value, maxLen) {
return `${normalized.slice(0, maxLen - 1)}…`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String.slice() can bisect a surrogate pair: JS string indexing is UTF-16, so normalized.slice(0, maxLen - 1) can split a surrogate pair (e.g., emoji like 🚀, U+1F680) at position maxLen - 1, producing a lone high surrogate followed by .... The resulting string is invalid Unicode and may render as ? or □ in some terminals.

💡 Suggested fix

Use Array.from to iterate codepoints instead of code units:

return Array.from(normalized).slice(0, maxLen - 1).join("") + "\u2026";

MCP tool descriptions are mostly ASCII today, so probability is low — but summarizeHelpText is a generic utility and the fix is trivial.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] When maxLen === 1, the function returns "..." — the single ellipsis character — with zero content characters. normalized.slice(0, maxLen - 1) = normalized.slice(0, 0) = "", so the output is just the marker with nothing before it.

None of the current constants reach maxLen=1, so this is not a production issue today, but the guard is incomplete.

💡 Suggestion

Either document the assumption (@param {number} maxLen - must be >= 2 to preserve at least one content character) or add a guard:

// Ensure at least one content character before the ellipsis
if (maxLen < 2) return normalized.slice(0, maxLen); // just truncate, no marker
return `${normalized.slice(0, maxLen - 1)}...`;

}

/**
* Render names as comma-separated compact lines and keep all names visible.
*
* @param {string[]} names
* @param {number} maxLines - Preferred line budget; non-positive/invalid values return one compact line
* @returns {string[]}
*/
function formatCompactNameLines(names, maxLines) {
if (!Array.isArray(names) || names.length === 0) {
return [];
}
if (!Number.isFinite(maxLines) || maxLines <= 0) {
return [` ${names.join(", ")}`];
}
const lines = [];
let current = " ";
for (const name of names) {
const token = current.trim() ? `, ${name}` : name;
const shouldStartNewLine = current.length + token.length > COMPACT_NAME_LINE_TARGET_WIDTH;
if (shouldStartNewLine) {
lines.push(current);
current = ` ${name}`;
continue;
}
current += token;
}
if (current.trim()) {
lines.push(current);
}
if (lines.length > maxLines) {
// Keep all names visible by collapsing overflow into the last allowed line.
const compactTail = lines
.slice(maxLines - 1)
.map(line => line.trim())
.join(", ");
return [...lines.slice(0, maxLines - 1), ` ${compactTail}`];
}
return lines;
}

// ---------------------------------------------------------------------------
// Response formatting
// ---------------------------------------------------------------------------
Expand Down
34 changes: 24 additions & 10 deletions actions/setup/js/mcp_cli_bridge.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,10 @@ describe("mcp_cli_bridge.cjs", () => {

const outputLines = stdoutChunks.join("").trimEnd().split("\n");
expect(outputLines.length).toBeLessThanOrEqual(20);
expect(outputLines.join("\n")).toMatch(/\.\.\. \+\d+ more command\(s\)/);
expect(outputLines.join("\n")).not.toMatch(/\.\.\. \+\d+ more command\(s\)/);
for (const tool of tools) {
expect(outputLines.join("\n")).toContain(tool.name);
}
});

it("does not truncate top-level help when commands exactly fit the line budget", () => {
Expand All @@ -234,8 +237,11 @@ describe("mcp_cli_bridge.cjs", () => {
showHelp("safeoutputs", tools);

const outputLines = stdoutChunks.join("").trimEnd().split("\n");
expect(outputLines.length).toBe(20);
expect(outputLines.length).toBeLessThanOrEqual(20);
expect(outputLines.join("\n")).not.toMatch(/\.\.\. \+\d+ more command\(s\)/);
for (const tool of tools) {
expect(outputLines.join("\n")).toContain(tool.name);
}
});
Comment on lines +215 to +230

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] summarizeHelpText is a pure, deterministic utility that now drives all help truncation, but it has no dedicated unit tests. Its edge cases — multi-space collapsing, exactly-at-limit strings, maxLen <= 0, and the maxLen - 1 slice boundary — are only exercised indirectly through showHelp / showToolHelp.

💡 Suggested test skeleton
describe("summarizeHelpText", () => {
  // Need to export it first, or test via a helper that calls it directly.
  it("collapses internal whitespace", () => {
    expect(summarizeHelpText("foo   bar\n  baz", 100)).toBe("foo bar baz");
  });
  it("returns full string when within limit", () => {
    expect(summarizeHelpText("hello", 5)).toBe("hello");
  });
  it("truncates with ellipsis when over limit", () => {
    expect(summarizeHelpText("hello world", 8)).toBe("hello w...");
  });
  it("returns normalized text when maxLen <= 0", () => {
    expect(summarizeHelpText("text", 0)).toBe("text");
  });
});

This would also catch the maxLen=1 edge case noted separately.


it("keeps command help compact for many options", () => {
Expand All @@ -256,11 +262,14 @@ describe("mcp_cli_bridge.cjs", () => {
]);

const outputLines = stdoutChunks.join("").trimEnd().split("\n");
expect(outputLines.length).toBeLessThanOrEqual(20);
expect(outputLines.join("\n")).toMatch(/\.\.\. \+\d+ more option\(s\)/);
expect(outputLines.length).toBeLessThanOrEqual(30);
expect(outputLines.join("\n")).not.toMatch(/\.\.\. \+\d+ more option\(s\)/);
expect(outputLines.join("\n")).toContain("Required options are marked with *.");
expect(outputLines.join("\n")).toMatch(/--field_1 \(string\)\*/);
expect(outputLines.join("\n")).toMatch(/--field_2 \(string\)\*/);
for (let i = 1; i <= 24; i++) {
expect(outputLines.join("\n")).toContain(`--field_${i}`);
}
expect(outputLines.join("\n")).toContain("--field_1*");
expect(outputLines.join("\n")).toContain("--field_2*");
});

it("does not truncate command help when options exactly fit the line budget", () => {
Expand All @@ -281,12 +290,15 @@ describe("mcp_cli_bridge.cjs", () => {
]);

const outputLines = stdoutChunks.join("").trimEnd().split("\n");
expect(outputLines.length).toBe(20);
expect(outputLines.length).toBeLessThanOrEqual(30);
expect(outputLines.join("\n")).not.toMatch(/\.\.\. \+\d+ more option\(s\)/);
expect(outputLines.join("\n")).toContain("Required options are marked with *.");
for (let i = 1; i <= 13; i++) {
expect(outputLines.join("\n")).toContain(`--field_${i}`);
}
});

it("omits required-note when required options are truncated", () => {
it("keeps required-note when required options are in the compact list", () => {
const properties = {};
for (let i = 1; i <= 24; i++) {
properties[`field_${i}`] = { type: "string", description: `Field ${i}.` };
Expand All @@ -304,8 +316,10 @@ describe("mcp_cli_bridge.cjs", () => {
]);

const outputLines = stdoutChunks.join("").trimEnd().split("\n");
expect(outputLines.join("\n")).toMatch(/\.\.\. \+\d+ more option\(s\)/);
expect(outputLines.join("\n")).not.toContain("Required options are marked with *.");
expect(outputLines.join("\n")).not.toMatch(/\.\.\. \+\d+ more option\(s\)/);
expect(outputLines.join("\n")).toContain("Required options are marked with *.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test only checks the footer string, not that * markers appear on option lines: field_1 and field_2 happen to be the first two properties, so they always survive truncation and receive * in this fixture — but the assertion never verifies it. If the requiredMark concatenation were accidentally dropped, this test would still pass.

💡 Suggested additions
// Verify * markers are actually on the option lines
expect(outputLines.join("\n")).toMatch(/--field_1 \(string\)\*/);
expect(outputLines.join("\n")).toMatch(/--field_2 \(string\)\*/);

A companion test with required: ["field_20"] (a field beyond the slice cutoff) would also cover the production bug on line 836 of mcp_cli_bridge.cjs — that fixture should produce no * markers and therefore no footer.

expect(outputLines.join("\n")).toContain("--field_23*");
expect(outputLines.join("\n")).toContain("--field_24*");
});

describe("stdin placeholder removed — '-' is always a literal value", () => {
Expand Down