From aa2385d2bb46293e937dcad3fd5a1ac95d6fcb52 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 14:48:28 +0100 Subject: [PATCH 1/5] 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 d126a15c12b8..5bbc3a14f061 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -181,6 +181,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 259ceeb4b775..275acbeac9f7 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -69,20 +69,71 @@ function parseFrontmatterBoolean(value: unknown): boolean | undefined { } } +// 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 32046f3f4e229483d622544cb90585eef7882494 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 15:16:28 +0100 Subject: [PATCH 2/5] 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 5bbc3a14f061..ec8bb42d5d8d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -216,6 +216,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 275acbeac9f7..0e2af6025135 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -82,9 +82,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> = {}; @@ -99,11 +101,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 071246ab0d0185bb146a24f6b2a85d8c243a92c2 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 21 Aug 2026 15:29:35 +0100 Subject: [PATCH 3/5] 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 ec8bb42d5d8d..6560d7cd126d 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -216,6 +216,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 0e2af6025135..c094b616d0c2 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -108,12 +108,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; } From 9921e46c50e882e5b0874d3d510abdd75b0d1812 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 19 Sep 2026 05:23:22 +0100 Subject: [PATCH 4/5] test(server): expect Claude skill directory names --- apps/server/src/provider/Drivers/ClaudeSkills.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 6560d7cd126d..07812b68c865 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -242,7 +242,7 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { assert.deepEqual(skills, [ { - name: "demo", + name: "commented", path: path.join(configDir, "skills", "commented", "SKILL.md"), enabled: true, scope: "user", From 613ce445f135a174d6755b0e6eb6e67fce72a2d7 Mon Sep 17 00:00:00 2001 From: James Date: Sat, 19 Sep 2026 05:43:13 +0100 Subject: [PATCH 5/5] fix(server): repair only lenient Claude skill scalars --- .../src/provider/Drivers/ClaudeSkills.test.ts | 5 + .../src/provider/Drivers/ClaudeSkills.ts | 108 +++++++----------- 2 files changed, 46 insertions(+), 67 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts index 07812b68c865..762b3d90eb1a 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.test.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.test.ts @@ -229,6 +229,9 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { [ "---", "name: demo # display label", + "allowed-tools: [Read, Write]", + "disable-model-invocation: yes", + "user-invocable: no", // 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 @@ -248,6 +251,8 @@ it.layer(NodeServices.layer)("discoverClaudeSkills", (it) => { scope: "user", description: "Browser automation + AI test authoring via kane-cli: run browser objectives, ...", + userInvocationOnly: true, + userInvocable: false, }, ]); }), diff --git a/apps/server/src/provider/Drivers/ClaudeSkills.ts b/apps/server/src/provider/Drivers/ClaudeSkills.ts index c094b616d0c2..70a2bb1c1e48 100644 --- a/apps/server/src/provider/Drivers/ClaudeSkills.ts +++ b/apps/server/src/provider/Drivers/ClaudeSkills.ts @@ -69,82 +69,40 @@ function parseFrontmatterBoolean(value: unknown): boolean | undefined { } } -// 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 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. + * Repairs the one YAML dialect difference observed in Claude Code: it accepts + * an unquoted scalar containing a `: ` sequence where the YAML parser rejects + * the whole document as an ambiguous nested mapping. Re-run the real parser + * after quoting only those top-level scalar values so all other fields keep + * normal YAML validation and parsed metadata. */ 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(); - const rawValue = rawLine.slice(separatorIndex + 1).trim(); - if (YAML_STRUCTURAL_VALUE_PATTERN.test(rawValue)) { - return { kind: "malformed" }; - } - if (key !== "name" && key !== "description") { - continue; - } - // 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; + const repairedSource = yamlSource + .split(/\r?\n/) + .map((line) => { + const match = /^(\s*[A-Za-z0-9_-]+:\s+)(.*)$/.exec(line); + const value = match?.[2]; + if (!match || value === undefined || !value.includes(": ")) { + return line; } - } catch { - // Not parseable in isolation either; keep the raw text. - } - 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] ?? ""; + if (value.startsWith('"') || value.startsWith("'")) { + return line; + } + const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + return `${match[1]}"${escaped}"`; + }) + .join("\n"); - let parsed: unknown; try { - parsed = parseYamlDocument(yamlSource); + return parseParsedSkillFrontmatter(parseYamlDocument(repairedSource)); } catch { - return parseSkillFrontmatterLeniently(yamlSource); + return { kind: "malformed" }; } +} + +function parseParsedSkillFrontmatter(parsed: unknown): SkillFrontmatter { if (typeof parsed !== "object" || parsed === null) { - return parseSkillFrontmatterLeniently(yamlSource); + return { kind: "malformed" }; } const record = parsed as Record; @@ -161,6 +119,22 @@ function parseSkillFrontmatter(contents: string): SkillFrontmatter { }; } +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(yamlSource); + } catch { + return parseSkillFrontmatterLeniently(yamlSource); + } + return parseParsedSkillFrontmatter(parsed); +} + /** * Where an administrator installs the policy file whose settings outrank every * user and project one. Absent on almost every machine, which is why a missing