From 017de12e635385e75e813f72cb929bf58eff139e Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 4 Oct 2026 02:46:07 +0000 Subject: [PATCH] fix(server): recover Claude skill scalar frontmatter --- .../src/provider/Drivers/ClaudeSkills.test.ts | 54 ++++++++++++++++++- .../src/provider/Drivers/ClaudeSkills.ts | 21 +++++++- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index d126a15c12b8..c4b328cdc337 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -154,6 +154,48 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("recovers colon-bearing descriptions with invocation metadata intact", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-skills-" }); + const configDir = path.join(tempDir, "claude-home"); + + for (const [description, comment] of [ + ["Browser automation + AI test authoring via kane-cli: run browser objectives, ...", ""], + ['Read C:\\skills\\guide#tag: continue with "quoted".', " # trailing: comment"], + ] as const) { + yield* writeSkill( + path.join(configDir, "skills"), + "kane-cli", + [ + "---", + "name: frontmatter-alias", + `description: ${description}${comment}`, + "allowed-tools: [Read, Write]", + "disable-model-invocation: yes", + "user-invocable: no", + "---", + ].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + + assert.deepEqual(skills, [ + { + name: "kane-cli", + path: path.join(configDir, "skills", "kane-cli", "SKILL.md"), + enabled: true, + scope: "user", + description, + userInvocationOnly: true, + userInvocable: false, + }, + ]); + } + }), + ); + it.effect("falls back to the directory name and skips malformed frontmatter", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -163,7 +205,17 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { const skillsDir = path.join(configDir, "skills"); yield* writeSkill(skillsDir, "no-frontmatter", "# Just a heading\n"); - yield* writeSkill(skillsDir, "broken-yaml", "---\nname: [unclosed\n---\n"); + for (const [directoryName, field] of [ + ["broken-yaml", "name: [unclosed"], + ["broken-tools", "allowed-tools: [Read, Write"], + ["broken-quoted", 'name: "unclosed: text'], + ] as const) { + yield* writeSkill( + skillsDir, + directoryName, + ["---", "description: Run: browser objectives.", field, "---"].join("\n"), + ); + } // A stray file (not a directory with SKILL.md) must be skipped. yield* fs.makeDirectory(skillsDir, { recursive: true }); yield* fs.writeFileString(path.join(skillsDir, "README.md"), "not a skill"); diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 259ceeb4b775..8c9c9c669faa 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -75,11 +75,28 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { return { kind: "missing" }; } + const frontmatter = match[1] ?? ""; let parsed: unknown; try { - parsed = parseYamlDocument(match[1] ?? ""); + parsed = parseYamlDocument(frontmatter); } catch { - return { kind: "malformed" }; + // Claude Code accepts plain scalars containing `: `. Repair only those, + // leaving comments and YAML structure for the full-document parser. + const repaired = frontmatter.replace( + /^([\w-]+:[ \t]*)([^\r\n]*)/gm, + (line, prefix: string, value: string) => { + const scalar = value.split(/[ \t]+#/)[0] ?? ""; + if (!/:[ \t]/.test(scalar) || /^(?:["'[\]{}|>&*!#%@`]|[-?:](?:[ \t]|$))/.test(scalar)) { + return line; + } + return `${prefix}${JSON.stringify(scalar)}${value.slice(scalar.length)}`; + }, + ); + try { + parsed = parseYamlDocument(repaired); + } catch { + return { kind: "malformed" }; + } } if (typeof parsed !== "object" || parsed === null) { return { kind: "malformed" };