From f97d0d156e68a99c0789d6173ee9a9a45cb97159 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 14:48:28 +0100 Subject: [PATCH 1/3] fix(server): recover skill frontmatter Claude Code itself accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseSkillFrontmatter strict-YAML-parsed SKILL.md frontmatter and dropped the entry entirely on any parse failure. Claude Code's own frontmatter parser is more lenient: an unquoted description containing a "word: " sequence (e.g. a URL or clause with a colon) is valid there but strict YAML rejects it as an ambiguous nested mapping. A skill that demonstrably loads in Claude Code was invisible in T3's own scanner. Add a fallback that recovers name/description as flat "key: value" scalars when strict parsing fails, but only when the value doesn't look like broken YAML syntax (an unterminated flow collection, block scalar, anchor, alias, or tag) — those still count as malformed, matching existing behavior for frontmatter Claude Code wouldn't load either. Fixes #7757 --- .../src/provider/Drivers/ClaudeSkills.test.ts | 35 ++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 57 ++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 60db1d0c5e26..eb914ff9d614 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -219,6 +219,41 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect( + "recovers a description containing an unquoted colon that Claude Code itself accepts", + () => + 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"); + + yield* writeSkill( + path.join(configDir, "skills"), + "kane-cli", + [ + "---", + "name: kane-cli", + "description: Browser automation + AI test authoring via kane-cli: run browser objectives, ...", + "---", + ].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: + "Browser automation + AI test authoring via kane-cli: run browser objectives, ...", + }, + ]); + }), + ); + it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 5c33fba0b9e9..d9f8dea03e68 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -30,20 +30,71 @@ type SkillFrontmatter = | { readonly kind: "malformed" } | { readonly kind: "parsed"; readonly name?: string; readonly description?: string }; +// A YAML flow/block construct starting here means the author was attempting +// real YAML nesting that broke, not a plain scalar that merely contains a +// colon — the lenient recovery below must not paper over that. +const YAML_STRUCTURAL_VALUE_PATTERN = /^[[{|>&*!]/; + +/** + * Recovers `name`/`description` as flat "key: value" scalars when strict YAML + * parsing rejects the frontmatter. Claude Code's own frontmatter parser is + * more lenient than a real YAML parser — it accepts an unquoted scalar + * description containing a "word: " sequence (e.g. "... via kane-cli: run + * ..."), which strict YAML rejects as an ambiguous nested mapping. A skill + * that demonstrably loads in Claude Code must not be invisible in T3 purely + * over that mismatch. This only recovers the two scalar fields we read, and + * only when the value doesn't look like broken YAML syntax (an unterminated + * flow collection, block scalar, anchor, alias, or tag) — those still count + * as malformed, since Claude Code wouldn't load them either. + */ +function parseSkillFrontmatterLeniently(yamlSource: string): SkillFrontmatter { + const fields: Partial> = {}; + for (const rawLine of yamlSource.split(/\r?\n/)) { + if (/^\s/.test(rawLine) || rawLine.trim().length === 0) { + // Indented (nested/continuation) or blank lines aren't a top-level + // scalar this recovery can safely reinterpret. + continue; + } + const separatorIndex = rawLine.indexOf(":"); + if (separatorIndex === -1) { + continue; + } + const key = rawLine.slice(0, separatorIndex).trim(); + if (key !== "name" && key !== "description") { + continue; + } + const rawValue = rawLine.slice(separatorIndex + 1).trim(); + if (YAML_STRUCTURAL_VALUE_PATTERN.test(rawValue)) { + continue; + } + const value = + rawValue.length >= 2 && + ((rawValue.startsWith('"') && rawValue.endsWith('"')) || + (rawValue.startsWith("'") && rawValue.endsWith("'"))) + ? rawValue.slice(1, -1) + : rawValue; + if (value.length > 0) { + fields[key] = value; + } + } + return Object.keys(fields).length > 0 ? { kind: "parsed", ...fields } : { kind: "malformed" }; +} + function parseSkillFrontmatter(contents: string): SkillFrontmatter { const match = FRONTMATTER_PATTERN.exec(contents); if (!match) { return { kind: "missing" }; } + const yamlSource = match[1] ?? ""; let parsed: unknown; try { - parsed = parseYamlDocument(match[1] ?? ""); + parsed = parseYamlDocument(yamlSource); } catch { - return { kind: "malformed" }; + return parseSkillFrontmatterLeniently(yamlSource); } if (typeof parsed !== "object" || parsed === null) { - return { kind: "malformed" }; + return parseSkillFrontmatterLeniently(yamlSource); } const record = parsed as Record; From 370566eb5cb83080fdc3665598fc34d5dad9d4de Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 15:16:28 +0100 Subject: [PATCH 2/3] fix(server): treat any broken frontmatter line as fully malformed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both Macroscope and Codex caught a real gap in the lenient fallback: it recovered name/description per-line independently, so a document with one genuinely broken field (e.g. name: [unclosed) alongside a fine one (description: ...) surfaced the skill anyway with the broken field silently dropped — exactly the case the fallback was supposed to exclude, since Claude Code wouldn't load that file at all. A broken line in a field this scanner doesn't even read had the same gap. Any top-level line whose value looks like broken YAML structure now fails the whole document as malformed, regardless of which field it's in, before recovering name/description from the rest. --- .../src/provider/Drivers/ClaudeSkills.test.ts | 44 +++++++++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 14 +++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index eb914ff9d614..cc708cedcda9 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -254,6 +254,50 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("skips the whole skill when a broken field survives alongside a recoverable one", () => + 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"); + + yield* writeSkill( + path.join(configDir, "skills"), + "broken-name", + ["---", "name: [unclosed", "description: Broken skill.", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + + // A broken `name` must not surface the skill under its directory name + // with only the description recovered — Claude Code wouldn't load + // this file at all. + assert.deepEqual(skills, []); + }), + ); + + it.effect("skips the whole skill when an unread field has broken YAML syntax", () => + 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"); + + yield* writeSkill( + path.join(configDir, "skills"), + "broken-other-field", + ["---", "name: demo", "allowed-tools: [unclosed", "---"].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + + // The broken field isn't one this scanner reads, but it still means + // the document has a real YAML syntax error Claude Code would reject + // outright — recovering `name` in isolation would be wrong. + assert.deepEqual(skills, []); + }), + ); + it.effect("honors CLAUDE_CONFIG_DIR from the environment when homePath is unset", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index d9f8dea03e68..19f9588f4a94 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -43,9 +43,11 @@ const YAML_STRUCTURAL_VALUE_PATTERN = /^[[{|>&*!]/; * ..."), which strict YAML rejects as an ambiguous nested mapping. A skill * that demonstrably loads in Claude Code must not be invisible in T3 purely * over that mismatch. This only recovers the two scalar fields we read, and - * only when the value doesn't look like broken YAML syntax (an unterminated - * flow collection, block scalar, anchor, alias, or tag) — those still count - * as malformed, since Claude Code wouldn't load them either. + * only when no top-level line's value looks like broken YAML syntax (an + * unterminated flow collection, block scalar, anchor, alias, or tag) — any + * such line, in this or another field, means the document has a real syntax + * error Claude Code wouldn't load either, so the whole file counts as + * malformed rather than surfacing a partial, plausible-looking recovery. */ function parseSkillFrontmatterLeniently(yamlSource: string): SkillFrontmatter { const fields: Partial> = {}; @@ -60,11 +62,11 @@ function parseSkillFrontmatterLeniently(yamlSource: string): SkillFrontmatter { continue; } const key = rawLine.slice(0, separatorIndex).trim(); - if (key !== "name" && key !== "description") { - continue; - } const rawValue = rawLine.slice(separatorIndex + 1).trim(); if (YAML_STRUCTURAL_VALUE_PATTERN.test(rawValue)) { + return { kind: "malformed" }; + } + if (key !== "name" && key !== "description") { continue; } const value = From 98a98baceb52729e3c38be312d1ae6fdbdfe6120 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 15:29:35 +0100 Subject: [PATCH 3/3] fix(server): parse recovered frontmatter values with real YAML scalar rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Macroscope caught a real gap: the lenient fallback copied everything after the colon verbatim, so "name: demo # display label" recovered as "demo # display label" instead of "demo" — no comment stripping, no quote-escape handling. Parse the isolated value with the real YAML parser instead of manual trimming. This also keeps the one case the fallback exists for working correctly: an unquoted value with its own embedded ": " parses as a one-entry mapping in isolation (not a string), so it falls through to the untouched raw text exactly as before. --- .../src/provider/Drivers/ClaudeSkills.test.ts | 37 +++++++++++++++++++ .../src/provider/Drivers/ClaudeSkills.ts | 21 ++++++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index cc708cedcda9..079e51e84d36 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -254,6 +254,43 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { }), ); + it.effect("strips a trailing comment from a recovered value instead of keeping it verbatim", () => + 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"); + + yield* writeSkill( + path.join(configDir, "skills"), + "commented", + [ + "---", + "name: demo # display label", + // The colon-containing description is what forces the lenient + // fallback to run at all — a comment alone wouldn't fail strict + // parsing, so this is needed to actually exercise the fallback's + // value parsing rather than the strict-YAML path. + "description: Browser automation + AI test authoring via kane-cli: run browser objectives, ...", + "---", + ].join("\n"), + ); + + const skills = yield* discoverClaudeSkills({ homePath: configDir }, undefined); + + assert.deepEqual(skills, [ + { + name: "demo", + path: path.join(configDir, "skills", "commented", "SKILL.md"), + enabled: true, + scope: "user", + description: + "Browser automation + AI test authoring via kane-cli: run browser objectives, ...", + }, + ]); + }), + ); + it.effect("skips the whole skill when a broken field survives alongside a recoverable one", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index 19f9588f4a94..3e83a166bcbe 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -69,12 +69,21 @@ function parseSkillFrontmatterLeniently(yamlSource: string): SkillFrontmatter { if (key !== "name" && key !== "description") { continue; } - const value = - rawValue.length >= 2 && - ((rawValue.startsWith('"') && rawValue.endsWith('"')) || - (rawValue.startsWith("'") && rawValue.endsWith("'"))) - ? rawValue.slice(1, -1) - : rawValue; + // Parse the value in isolation with the real YAML parser rather than + // just trimming it: a plain scalar with a trailing "# comment" or one + // quoted with escapes needs real YAML scalar rules to come out right. + // The one case this is *for* — an unquoted value with its own embedded + // ": " — parses as a one-entry mapping in isolation too, not a string, + // so it correctly falls through to the untouched raw text below. + let value = rawValue; + try { + const parsedValue: unknown = parseYamlDocument(rawValue); + if (typeof parsedValue === "string") { + value = parsedValue; + } + } catch { + // Not parseable in isolation either; keep the raw text. + } if (value.length > 0) { fields[key] = value; }