fix(skills): resolve skills info name mismatches (#38713)

Summary:
- The PR updates the skills CLI formatter, tests, and changelog so `skills info` resolves case-insensitive and ... ator-normalized skill name variants only when non-exact matches are unique, and sanitizes not-found output.
- Reproducibility: yes. by source inspection. The documented `openclaw skills info <name>` command passes the  ... ormatter lookup on current main, while skill status entries can have distinct `name` and `skillKey` values.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(skills): exercise case-insensitive lookup branch
- PR branch already contained follow-up commit before automerge: style(skills): format lookup resolver signature
- PR branch already contained follow-up commit before automerge: fix(skills): sanitize not-found output and avoid ambiguous lookup mat…
- PR branch already contained follow-up commit before automerge: fix(skills): require unique case-insensitive info matches

Validation:
- ClawSweeper review passed for head 01f3e2d468.
- Required merge gates passed before the squash merge.

Prepared head SHA: 01f3e2d468
Review: https://github.com/openclaw/openclaw/pull/38713#issuecomment-4321021300

Co-authored-by: NewdlDewdl <rohin.agrawal@gmail.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
Rohin
2026-05-18 12:00:57 -05:00
committed by GitHub
parent 1fbb4e4e6a
commit 9cbe28d75e
3 changed files with 121 additions and 31 deletions
+1
View File
@@ -5185,6 +5185,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- CLI/skills: require unique case-insensitive fallback matches in `openclaw skills info` so case-only collisions return not-found instead of showing guidance for the wrong skill. (#38713)
- Agents/Ollama: forward the configured embedded-run timeout into the global undici stream timeout tuning so slow local Ollama runs no longer inherit the default stream cutoff instead of the operator-set run timeout. (#63175) Thanks @mindcraftreader and @vincentkoc.
- Models/Codex: include `apiKey` in the codex provider catalog output so the Pi ModelRegistry validator no longer rejects the entry and silently drops all custom models from every provider in `models.json`. (#66180) Thanks @hoyyeva.
- Tools/image+pdf: normalize configured provider/model refs before media-tool registry lookup so image and PDF tool runs stop rejecting valid Ollama vision models as unknown just because the tool path skipped the usual model-ref normalization step. (#59943) Thanks @yqli2420 and @vincentkoc.
+62 -3
View File
@@ -103,6 +103,59 @@ function formatSkillMissingSummary(skill: SkillStatusEntry): string {
return missing.join("; ");
}
function normalizeSkillLookupToken(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[\s_/]+/g, "-")
.replace(/[^a-z0-9-]+/g, "")
.replace(/-+/g, "-")
.replace(/^-+|-+$/g, "");
}
function resolveSkillByName(
report: SkillStatusReport,
requestedName: string,
): SkillStatusEntry | null {
const raw = requestedName.trim();
if (!raw) {
return null;
}
const direct = report.skills.find((s) => s.name === raw || s.skillKey === raw);
if (direct) {
return direct;
}
const lower = raw.toLowerCase();
const caseInsensitiveMatches = report.skills.filter(
(s) => s.name.toLowerCase() === lower || s.skillKey.toLowerCase() === lower,
);
if (caseInsensitiveMatches.length === 1) {
return caseInsensitiveMatches[0] ?? null;
}
if (caseInsensitiveMatches.length > 1) {
return null;
}
const normalized = normalizeSkillLookupToken(raw);
if (!normalized) {
return null;
}
const normalizedMatches = report.skills.filter(
(s) =>
normalizeSkillLookupToken(s.name) === normalized ||
normalizeSkillLookupToken(s.skillKey) === normalized,
);
if (normalizedMatches.length !== 1) {
return null;
}
return normalizedMatches[0] ?? null;
}
export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOptions): string {
const isReadyForAgent = (skill: SkillStatusEntry) =>
skill.eligible && !skill.blockedByAgentFilter;
@@ -183,14 +236,20 @@ export function formatSkillInfo(
skillName: string,
opts: SkillInfoOptions,
): string {
const skill = report.skills.find((s) => s.name === skillName || s.skillKey === skillName);
const requestedName = skillName.trim();
const safeRequestedName = sanitizeJsonString(sanitizeForLog(requestedName));
const skill = resolveSkillByName(report, requestedName);
if (!skill) {
if (opts.json) {
return JSON.stringify({ error: "not found", skill: skillName }, null, 2);
return JSON.stringify(
sanitizeJsonValue({ error: "not found", skill: requestedName }),
null,
2,
);
}
return appendClawHubHint(
`Skill "${skillName}" not found. Run \`${formatCliCommand("openclaw skills list")}\` to see available skills.`,
`Skill "${safeRequestedName}" not found. Run \`${formatCliCommand("openclaw skills list")}\` to see available skills.`,
opts.json,
);
}
+58 -28
View File
@@ -180,46 +180,66 @@ describe("skills-cli", () => {
expect(output).toContain("API_KEY");
});
it("shows API key storage guidance for the active config path", () => {
it("resolves skill info case-insensitively", () => {
const report = createMockReport([
createMockSkill({
name: "env-aware-skill",
skillKey: "env-aware-skill",
primaryEnv: "API_KEY",
eligible: false,
requirements: {
bins: [],
anyBins: [],
env: ["API_KEY"],
config: [],
os: [],
},
missing: {
bins: [],
anyBins: [],
env: ["API_KEY"],
config: [],
os: [],
},
name: "Excel XLSX",
skillKey: "Excel-XLSX",
description: "Spreadsheet helpers",
}),
]);
const output = formatSkillInfo(report, "env-aware-skill", {});
expect(output).toContain("OPENCLAW_CONFIG_PATH");
expect(output).toContain("default: ~/.openclaw/openclaw.json");
expect(output).toContain("skills.entries.env-aware-skill.apiKey");
const output = formatSkillInfo(report, "excel-xlsx", {});
expect(output).toContain("Spreadsheet helpers");
});
it("normalizes text-presentation emoji selectors in info output", () => {
it("resolves skill info across separator variants", () => {
const report = createMockReport([
createMockSkill({
name: "info-emoji",
emoji: "🎛\uFE0E",
name: "Excel XLSX",
skillKey: "excel_xlsx",
description: "Spreadsheet helpers",
}),
]);
const output = formatSkillInfo(report, "info-emoji", {});
expect(output).toContain("🎛️");
const output = formatSkillInfo(report, "excel-xlsx", {});
expect(output).toContain("Spreadsheet helpers");
});
it("returns not found for ambiguous case-insensitive matches", () => {
const report = createMockReport([
createMockSkill({ name: "First Skill", skillKey: "Excel-XLSX", description: "first" }),
createMockSkill({ name: "Second Skill", skillKey: "excel-xlsx", description: "second" }),
]);
const output = formatSkillInfo(report, "EXCEL-XLSX", {});
expect(output).toContain("not found");
expect(output).not.toContain("first");
expect(output).not.toContain("second");
});
it("returns not found for ambiguous normalized matches", () => {
const report = createMockReport([
createMockSkill({ name: "Excel/XLSX", skillKey: "excel-slash", description: "first" }),
createMockSkill({
name: "Excel_XLSX",
skillKey: "excel-underscore",
description: "second",
}),
]);
const output = formatSkillInfo(report, "excel-xlsx", {});
expect(output).toContain("not found");
expect(output).not.toContain("first");
expect(output).not.toContain("second");
});
it("sanitizes user-supplied skill name in not-found text output", () => {
const report = createMockReport([]);
const output = formatSkillInfo(report, "evil\u001b[31m\u009f", {});
expect(output).toContain('Skill "evil" not found');
expect(output).not.toContain("\u001b");
});
it("shows agent exclusion and visibility details in skill info", () => {
@@ -482,5 +502,15 @@ describe("skills-cli", () => {
expect(parsed.description).toBe("hi");
expect(parsed.homepage).toBe("https://example.com/docs");
});
it("sanitizes user-supplied skill name in not-found JSON output", () => {
const report = createMockReport([]);
const output = formatSkillInfo(report, "evil\u001b[31m\u009f", { json: true });
const parsed = JSON.parse(output) as { error: string; skill: string };
expect(parsed.error).toBe("not found");
expect(parsed.skill).toBe("evil");
expect(output).not.toContain("\u001b");
});
});
});