Skip to content

Commit 9cbe28d

Browse files
NewdlDewdlclawsweeper[bot]Takhoffman
authored
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 01f3e2d. - Required merge gates passed before the squash merge. Prepared head SHA: 01f3e2d Review: #38713 (comment) 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>
1 parent 1fbb4e4 commit 9cbe28d

3 files changed

Lines changed: 121 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5185,6 +5185,7 @@ Docs: https://docs.openclaw.ai
51855185

51865186
### Fixes
51875187

5188+
- 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)
51885189
- 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.
51895190
- 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.
51905191
- 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.

src/cli/skills-cli.format.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,59 @@ function formatSkillMissingSummary(skill: SkillStatusEntry): string {
103103
return missing.join("; ");
104104
}
105105

106+
function normalizeSkillLookupToken(value: string): string {
107+
return value
108+
.trim()
109+
.toLowerCase()
110+
.replace(/[\s_/]+/g, "-")
111+
.replace(/[^a-z0-9-]+/g, "")
112+
.replace(/-+/g, "-")
113+
.replace(/^-+|-+$/g, "");
114+
}
115+
116+
function resolveSkillByName(
117+
report: SkillStatusReport,
118+
requestedName: string,
119+
): SkillStatusEntry | null {
120+
const raw = requestedName.trim();
121+
if (!raw) {
122+
return null;
123+
}
124+
125+
const direct = report.skills.find((s) => s.name === raw || s.skillKey === raw);
126+
if (direct) {
127+
return direct;
128+
}
129+
130+
const lower = raw.toLowerCase();
131+
const caseInsensitiveMatches = report.skills.filter(
132+
(s) => s.name.toLowerCase() === lower || s.skillKey.toLowerCase() === lower,
133+
);
134+
if (caseInsensitiveMatches.length === 1) {
135+
return caseInsensitiveMatches[0] ?? null;
136+
}
137+
if (caseInsensitiveMatches.length > 1) {
138+
return null;
139+
}
140+
141+
const normalized = normalizeSkillLookupToken(raw);
142+
if (!normalized) {
143+
return null;
144+
}
145+
146+
const normalizedMatches = report.skills.filter(
147+
(s) =>
148+
normalizeSkillLookupToken(s.name) === normalized ||
149+
normalizeSkillLookupToken(s.skillKey) === normalized,
150+
);
151+
152+
if (normalizedMatches.length !== 1) {
153+
return null;
154+
}
155+
156+
return normalizedMatches[0] ?? null;
157+
}
158+
106159
export function formatSkillsList(report: SkillStatusReport, opts: SkillsListOptions): string {
107160
const isReadyForAgent = (skill: SkillStatusEntry) =>
108161
skill.eligible && !skill.blockedByAgentFilter;
@@ -183,14 +236,20 @@ export function formatSkillInfo(
183236
skillName: string,
184237
opts: SkillInfoOptions,
185238
): string {
186-
const skill = report.skills.find((s) => s.name === skillName || s.skillKey === skillName);
239+
const requestedName = skillName.trim();
240+
const safeRequestedName = sanitizeJsonString(sanitizeForLog(requestedName));
241+
const skill = resolveSkillByName(report, requestedName);
187242

188243
if (!skill) {
189244
if (opts.json) {
190-
return JSON.stringify({ error: "not found", skill: skillName }, null, 2);
245+
return JSON.stringify(
246+
sanitizeJsonValue({ error: "not found", skill: requestedName }),
247+
null,
248+
2,
249+
);
191250
}
192251
return appendClawHubHint(
193-
`Skill "${skillName}" not found. Run \`${formatCliCommand("openclaw skills list")}\` to see available skills.`,
252+
`Skill "${safeRequestedName}" not found. Run \`${formatCliCommand("openclaw skills list")}\` to see available skills.`,
194253
opts.json,
195254
);
196255
}

src/cli/skills-cli.test.ts

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -180,46 +180,66 @@ describe("skills-cli", () => {
180180
expect(output).toContain("API_KEY");
181181
});
182182

183-
it("shows API key storage guidance for the active config path", () => {
183+
it("resolves skill info case-insensitively", () => {
184184
const report = createMockReport([
185185
createMockSkill({
186-
name: "env-aware-skill",
187-
skillKey: "env-aware-skill",
188-
primaryEnv: "API_KEY",
189-
eligible: false,
190-
requirements: {
191-
bins: [],
192-
anyBins: [],
193-
env: ["API_KEY"],
194-
config: [],
195-
os: [],
196-
},
197-
missing: {
198-
bins: [],
199-
anyBins: [],
200-
env: ["API_KEY"],
201-
config: [],
202-
os: [],
203-
},
186+
name: "Excel XLSX",
187+
skillKey: "Excel-XLSX",
188+
description: "Spreadsheet helpers",
204189
}),
205190
]);
206191

207-
const output = formatSkillInfo(report, "env-aware-skill", {});
208-
expect(output).toContain("OPENCLAW_CONFIG_PATH");
209-
expect(output).toContain("default: ~/.openclaw/openclaw.json");
210-
expect(output).toContain("skills.entries.env-aware-skill.apiKey");
192+
const output = formatSkillInfo(report, "excel-xlsx", {});
193+
expect(output).toContain("Spreadsheet helpers");
211194
});
212195

213-
it("normalizes text-presentation emoji selectors in info output", () => {
196+
it("resolves skill info across separator variants", () => {
214197
const report = createMockReport([
215198
createMockSkill({
216-
name: "info-emoji",
217-
emoji: "🎛\uFE0E",
199+
name: "Excel XLSX",
200+
skillKey: "excel_xlsx",
201+
description: "Spreadsheet helpers",
218202
}),
219203
]);
220204

221-
const output = formatSkillInfo(report, "info-emoji", {});
222-
expect(output).toContain("🎛️");
205+
const output = formatSkillInfo(report, "excel-xlsx", {});
206+
expect(output).toContain("Spreadsheet helpers");
207+
});
208+
209+
it("returns not found for ambiguous case-insensitive matches", () => {
210+
const report = createMockReport([
211+
createMockSkill({ name: "First Skill", skillKey: "Excel-XLSX", description: "first" }),
212+
createMockSkill({ name: "Second Skill", skillKey: "excel-xlsx", description: "second" }),
213+
]);
214+
215+
const output = formatSkillInfo(report, "EXCEL-XLSX", {});
216+
expect(output).toContain("not found");
217+
expect(output).not.toContain("first");
218+
expect(output).not.toContain("second");
219+
});
220+
221+
it("returns not found for ambiguous normalized matches", () => {
222+
const report = createMockReport([
223+
createMockSkill({ name: "Excel/XLSX", skillKey: "excel-slash", description: "first" }),
224+
createMockSkill({
225+
name: "Excel_XLSX",
226+
skillKey: "excel-underscore",
227+
description: "second",
228+
}),
229+
]);
230+
231+
const output = formatSkillInfo(report, "excel-xlsx", {});
232+
expect(output).toContain("not found");
233+
expect(output).not.toContain("first");
234+
expect(output).not.toContain("second");
235+
});
236+
237+
it("sanitizes user-supplied skill name in not-found text output", () => {
238+
const report = createMockReport([]);
239+
const output = formatSkillInfo(report, "evil\u001b[31m\u009f", {});
240+
241+
expect(output).toContain('Skill "evil" not found');
242+
expect(output).not.toContain("\u001b");
223243
});
224244

225245
it("shows agent exclusion and visibility details in skill info", () => {
@@ -482,5 +502,15 @@ describe("skills-cli", () => {
482502
expect(parsed.description).toBe("hi");
483503
expect(parsed.homepage).toBe("https://example.com/docs");
484504
});
505+
506+
it("sanitizes user-supplied skill name in not-found JSON output", () => {
507+
const report = createMockReport([]);
508+
const output = formatSkillInfo(report, "evil\u001b[31m\u009f", { json: true });
509+
const parsed = JSON.parse(output) as { error: string; skill: string };
510+
511+
expect(parsed.error).toBe("not found");
512+
expect(parsed.skill).toBe("evil");
513+
expect(output).not.toContain("\u001b");
514+
});
485515
});
486516
});

0 commit comments

Comments
 (0)