mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
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 head01f3e2d468. - Required merge gates passed before the squash merge. Prepared head SHA:01f3e2d468Review: 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:
@@ -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.
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user