fix(terminal): sanitize skills JSON and fallback on legacy Windows (#43520)

* Terminal: use ASCII borders on legacy Windows consoles

* Skills: sanitize JSON output for control bytes

* Changelog: credit terminal follow-up fixes

* Update CHANGELOG.md

* Update CHANGELOG.md

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Skills: strip remaining escape sequences from JSON output

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Vincent Koc
2026-03-11 19:53:07 -04:00
committed by GitHub
parent 0e397e62b7
commit b6d83749c8
5 changed files with 143 additions and 7 deletions

View File

@@ -1,4 +1,5 @@
import type { SkillStatusEntry, SkillStatusReport } from "../agents/skills-status.js";
import { stripAnsi } from "../terminal/ansi.js";
import { getTerminalTableWidth, renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
@@ -42,6 +43,33 @@ function normalizeSkillEmoji(emoji?: string): string {
return (emoji ?? "📦").replaceAll("\uFE0E", "\uFE0F");
}
const REMAINING_ESC_SEQUENCE_REGEX = new RegExp(
String.raw`\u001b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])`,
"g",
);
const JSON_CONTROL_CHAR_REGEX = new RegExp(String.raw`[\u0000-\u001f\u007f-\u009f]`, "g");
function sanitizeJsonString(value: string): string {
return stripAnsi(value)
.replace(REMAINING_ESC_SEQUENCE_REGEX, "")
.replace(JSON_CONTROL_CHAR_REGEX, "");
}
function sanitizeJsonValue(value: unknown): unknown {
if (typeof value === "string") {
return sanitizeJsonString(value);
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeJsonValue(item));
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entryValue]) => [key, sanitizeJsonValue(entryValue)]),
);
}
return value;
}
function formatSkillName(skill: SkillStatusEntry): string {
const emoji = normalizeSkillEmoji(skill.emoji);
return `${emoji} ${theme.command(skill.name)}`;
@@ -71,7 +99,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti
const skills = opts.eligible ? report.skills.filter((s) => s.eligible) : report.skills;
if (opts.json) {
const jsonReport = {
const jsonReport = sanitizeJsonValue({
workspaceDir: report.workspaceDir,
managedSkillsDir: report.managedSkillsDir,
skills: skills.map((s) => ({
@@ -87,7 +115,7 @@ export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOpti
homepage: s.homepage,
missing: s.missing,
})),
};
});
return JSON.stringify(jsonReport, null, 2);
}
@@ -154,7 +182,7 @@ export function formatSkillInfo(
}
if (opts.json) {
return JSON.stringify(skill, null, 2);
return JSON.stringify(sanitizeJsonValue(skill), null, 2);
}
const lines: string[] = [];
@@ -251,7 +279,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
if (opts.json) {
return JSON.stringify(
{
sanitizeJsonValue({
summary: {
total: report.skills.length,
eligible: eligible.length,
@@ -267,7 +295,7 @@ export function formatSkillsCheck(report: SkillStatusReport, opts: SkillsCheckOp
missing: s.missing,
install: s.install,
})),
},
}),
null,
2,
);

View File

@@ -243,5 +243,46 @@ describe("skills-cli", () => {
const parsed = JSON.parse(output) as Record<string, unknown>;
assert(parsed);
});
it("sanitizes ANSI and C1 controls in skills list JSON output", () => {
const report = createMockReport([
createMockSkill({
name: "json-skill",
emoji: "\u001b[31m📧\u001b[0m\u009f",
description: "desc\u0093\u001b[2J\u001b[33m colored\u001b[0m",
}),
]);
const output = formatSkillsList(report, { json: true });
const parsed = JSON.parse(output) as {
skills: Array<{ emoji: string; description: string }>;
};
expect(parsed.skills[0]?.emoji).toBe("📧");
expect(parsed.skills[0]?.description).toBe("desc colored");
expect(output).not.toContain("\\u001b");
});
it("sanitizes skills info JSON output", () => {
const report = createMockReport([
createMockSkill({
name: "info-json",
emoji: "\u001b[31m🎙\u001b[0m\u009f",
description: "hi\u0091",
homepage: "https://example.com/\u0092docs",
}),
]);
const output = formatSkillInfo(report, "info-json", { json: true });
const parsed = JSON.parse(output) as {
emoji: string;
description: string;
homepage: string;
};
expect(parsed.emoji).toBe("🎙");
expect(parsed.description).toBe("hi");
expect(parsed.homepage).toBe("https://example.com/docs");
});
});
});