From 5f6490c9002b81505e915ef1dd0f022f9b24a52c Mon Sep 17 00:00:00 2001 From: marshall Date: Mon, 13 Jul 2026 09:18:21 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(compat):=20model-invocable=20Skill=20t?= =?UTF-8?q?ool=20=E2=80=94=20progressive=20disclosure=20via=20index=20bloc?= =?UTF-8?q?k=20+=20on-demand=20body=20(#120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills discovery (loadSkills) previously stopped at parsing; nothing let a subagent invoke a skill and no builder rendered a skill index. Add the two missing tiers of progressive disclosure plus the parser fixes they need. - skill-tool.ts (new): `skillIndexBlock(skills)` renders the always-visible tier — the blocking invocation contract plus one `: ` line per skill; `skillTool(skills)` is the AI-SDK `Skill` tool that pulls a matched skill's body + absolute path into context on demand, rendered as plain text (toModelOutput) so it reads as instructions. Unknown names return an error naming the available skills — never throw. Skills with `disable-model-invocation: true` are excluded from both the index and the tool's matchable set. - frontmatter.ts: block-scalar support (`|` literal / `>` folded, with `-` chomping). Multi-line skill descriptions previously parsed as the bare marker and dropped their content. Stays hand-rolled, dependency-free. - skills-loader.ts: `SkillDefinition` gains `extra: Record` carrying every frontmatter key other than name/description, so callers can honor `allowed-tools`, `disable-model-invocation`, etc. - index.ts: export the new surface. Mechanism only: zero content, zero aitm wiring, so nothing changes at runtime when it lands. The caller passes the `skills` array, deciding what a role may mount; target-repo skills are untrusted (Worker-only) and their wiring is a separate aitm-side issue. Part of #100. --- .../ai-claude-compat/src/frontmatter.test.ts | 41 ++++++ packages/ai-claude-compat/src/frontmatter.ts | 65 ++++++++- packages/ai-claude-compat/src/index.ts | 7 + .../ai-claude-compat/src/skill-tool.test.ts | 124 ++++++++++++++++++ packages/ai-claude-compat/src/skill-tool.ts | 94 +++++++++++++ .../src/skills-loader.test.ts | 34 +++++ .../ai-claude-compat/src/skills-loader.ts | 10 +- 7 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 packages/ai-claude-compat/src/skill-tool.test.ts create mode 100644 packages/ai-claude-compat/src/skill-tool.ts diff --git a/packages/ai-claude-compat/src/frontmatter.test.ts b/packages/ai-claude-compat/src/frontmatter.test.ts index 7088138..f90f770 100644 --- a/packages/ai-claude-compat/src/frontmatter.test.ts +++ b/packages/ai-claude-compat/src/frontmatter.test.ts @@ -72,3 +72,44 @@ test('asStringArray: passes arrays, splits scalars, undefined when absent', () = assert.equal(asStringArray(undefined), undefined); assert.equal(asStringArray(''), undefined); }); + +// ---- block scalars (issue #120) ---- + +test('parseFrontmatter: folded block scalar (>) joins lines with spaces, clip keeps one newline', () => { + const { data } = parseFrontmatter('---\ndescription: >\n line one\n line two\n---\nbody'); + assert.equal(data.description, 'line one line two\n'); +}); + +test('parseFrontmatter: folded strip (>-) drops the trailing newline', () => { + const { data } = parseFrontmatter('---\ndescription: >-\n line one\n line two\n---\nbody'); + assert.equal(data.description, 'line one line two'); +}); + +test('parseFrontmatter: literal block scalar (|) preserves interior newlines', () => { + const { data } = parseFrontmatter('---\nsteps: |\n first\n second\n---\nbody'); + assert.equal(data.steps, 'first\nsecond\n'); +}); + +test('parseFrontmatter: literal strip (|-) keeps interior newlines but strips the trailing one', () => { + const { data } = parseFrontmatter('---\nsteps: |-\n a\n b\n---\nbody'); + assert.equal(data.steps, 'a\nb'); +}); + +test('parseFrontmatter: a folded blank line becomes a paragraph break', () => { + const { data } = parseFrontmatter('---\ndesc: >-\n para one\n\n para two\n---\nb'); + assert.equal(data.desc, 'para one\npara two'); +}); + +test('parseFrontmatter: a block scalar ends at the next top-level key, which still parses', () => { + const { data } = parseFrontmatter( + '---\nname: s\ndescription: >-\n folded value\n continues\nallowed-tools: [Read]\n---\nbody', + ); + assert.equal(data.name, 's'); + assert.equal(data.description, 'folded value continues'); + assert.deepEqual(data['allowed-tools'], ['Read']); +}); + +test('parseFrontmatter: `key: >text` on one line stays a plain scalar, not a block header', () => { + const { data } = parseFrontmatter('---\narrow: >text here\n---\nb'); + assert.equal(data.arrow, '>text here'); +}); diff --git a/packages/ai-claude-compat/src/frontmatter.ts b/packages/ai-claude-compat/src/frontmatter.ts index 41c81ff..90a76c4 100644 --- a/packages/ai-claude-compat/src/frontmatter.ts +++ b/packages/ai-claude-compat/src/frontmatter.ts @@ -1,9 +1,10 @@ // Minimal YAML-frontmatter parser for `.claude/` extension files (SKILL.md, agents/*.md) and the // memory format (issue #118). Handles only what those files use — scalar strings, flow arrays -// (`[a, b]`), block sequences (`- a` lines), and one level of nested map (indented `key: value` -// lines under a bare parent key) — so the lib stays dependency-free rather than pulling a full YAML -// engine. Anything outside that shape is ignored, not errored, so a malformed field never blocks -// discovery of the rest. +// (`[a, b]`), block sequences (`- a` lines), one level of nested map (indented `key: value` lines +// under a bare parent key), and block scalars (`key: |` / `key: >`, with `-` chomping — issue #120, +// where multi-line skill descriptions live) — so the lib stays dependency-free rather than pulling a +// full YAML engine. Anything outside that shape is ignored, not errored, so a malformed field never +// blocks discovery of the rest. export type FrontmatterValue = string | string[] | Record; export type Frontmatter = { data: Record; body: string }; @@ -26,7 +27,13 @@ function parseBlock(raw: string): Record { if (!m) continue; const key = m[1] ?? ''; const rest = (m[2] ?? '').trim(); - if (rest === '') { + if (BLOCK_SCALAR.test(rest)) { + // `key: |` / `key: >` head an indented block; the value spans the following more-indented + // lines. Multi-line skill descriptions (issue #120) are the reason this exists. + const { value, next } = parseBlockScalar(lines, i, rest); + data[key] = value; + i = next - 1; + } else if (rest === '') { // A bare `key:` heads either a nested map (indented `subkey: value` lines) or a block // sequence (`- item` lines). An indented `key:` line wins — it's a map, not a sequence item. if (/^[ \t]+[A-Za-z0-9_-]+:/.test(lines[i + 1] ?? '')) { @@ -55,6 +62,54 @@ function parseBlock(raw: string): Record { return data; } +// A block-scalar header is a lone `|` or `>` (literal / folded) with an optional `+`/`-` chomping +// indicator. `key: >text` is a plain scalar, not a block — the header must stand alone. +const BLOCK_SCALAR = /^([|>])([+-]?)$/; + +// Consume the indented lines following a `key: |` / `key: >` header into a single string. `folded` +// (`>`) joins lines with spaces and blank lines with newlines; literal (`|`) keeps every newline. +// The block ends at the first non-blank line that dedents to the header's column (the next key) or +// below the block's own indent. Default chomping keeps one trailing newline; `-` strips it. +function parseBlockScalar( + lines: string[], + keyIndex: number, + header: string, +): { value: string; next: number } { + const folded = header[0] === '>'; + const strip = header[1] === '-'; + const collected: string[] = []; + let baseIndent = -1; + let i = keyIndex + 1; + for (; i < lines.length; i++) { + const line = lines[i] ?? ''; + if (line.trim() === '') { + collected.push(''); + continue; + } + const indent = line.length - line.trimStart().length; + if (indent === 0) break; + if (baseIndent === -1) baseIndent = indent; + if (indent < baseIndent) break; + collected.push(line.slice(baseIndent)); + } + // Trailing blank lines are the separator before the next key, not part of the value. + while (collected.length > 0 && collected[collected.length - 1] === '') collected.pop(); + const assembled = assembleBlockScalar(collected, folded); + const value = strip || assembled === '' ? assembled : `${assembled}\n`; + return { value, next: i }; +} + +function assembleBlockScalar(lines: string[], folded: boolean): string { + if (!folded) return lines.join('\n'); + let out = ''; + for (const line of lines) { + if (line === '') out += '\n'; + else if (out === '' || out.endsWith('\n')) out += line; + else out += ` ${line}`; + } + return out; +} + function stripQuotes(s: string): string { if (s.length >= 2) { const first = s[0]; diff --git a/packages/ai-claude-compat/src/index.ts b/packages/ai-claude-compat/src/index.ts index d9063fa..fbe890d 100644 --- a/packages/ai-claude-compat/src/index.ts +++ b/packages/ai-claude-compat/src/index.ts @@ -117,6 +117,13 @@ export { globToRegExp, grepTool, } from './search-tools.ts'; +export { + SKILL_INVOCATION_CONTRACT, + type SkillToolInput, + type SkillToolOutput, + skillIndexBlock, + skillTool, +} from './skill-tool.ts'; export { loadSkills, type SkillDefinition } from './skills-loader.ts'; export { callWithStepTimeout, diff --git a/packages/ai-claude-compat/src/skill-tool.test.ts b/packages/ai-claude-compat/src/skill-tool.test.ts new file mode 100644 index 0000000..40ff05f --- /dev/null +++ b/packages/ai-claude-compat/src/skill-tool.test.ts @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import type { SkillToolInput, SkillToolOutput } from './skill-tool.ts'; +import { SKILL_INVOCATION_CONTRACT, skillIndexBlock, skillTool } from './skill-tool.ts'; +import type { SkillDefinition } from './skills-loader.ts'; + +function skill(partial: Partial & { name: string }): SkillDefinition { + return { + name: partial.name, + description: partial.description ?? `desc for ${partial.name}`, + body: partial.body ?? `body for ${partial.name}`, + path: partial.path ?? `/repo/.claude/skills/${partial.name}/SKILL.md`, + extra: partial.extra ?? {}, + }; +} + +function runExec(t: { execute?: unknown }, input: SkillToolInput): SkillToolOutput { + const exec = t.execute; + if (typeof exec !== 'function') throw new Error('tool has no execute'); + return ( + exec as (i: SkillToolInput, o: { toolCallId: string; messages: never[] }) => SkillToolOutput + )(input, { toolCallId: 'c', messages: [] }); +} + +function textOf( + t: { toModelOutput?: unknown }, + input: SkillToolInput, + output: SkillToolOutput, +): string { + const fn = t.toModelOutput; + if (typeof fn !== 'function') throw new Error('tool has no toModelOutput'); + const part = ( + fn as (o: { toolCallId: string; input: SkillToolInput; output: SkillToolOutput }) => { + type: string; + value: string; + } + )({ toolCallId: 'c', input, output }); + assert.equal(part.type, 'text'); + return part.value; +} + +test('skillIndexBlock: contract preamble + one line per invocable skill; disabled excluded (issue #120)', () => { + const block = skillIndexBlock([ + skill({ name: 'repo-recon', description: 'map a repo before editing' }), + skill({ + name: 'secret', + description: 'hidden', + extra: { 'disable-model-invocation': 'true' }, + }), + ]); + assert.ok(block.startsWith(SKILL_INVOCATION_CONTRACT), 'the invocation contract is the preamble'); + assert.match(block, /\nrepo-recon: map a repo before editing\n<\/skills>/); + assert.equal(block.includes('secret'), false, 'disable-model-invocation excludes from the index'); +}); + +test('skillIndexBlock: collapses a multi-line description to a single index line (issue #120)', () => { + const block = skillIndexBlock([skill({ name: 'x', description: 'line one\nline two' })]); + assert.match(block, /x: line one line two/); + assert.equal( + block.split('\n').filter((l) => l.startsWith('x:')).length, + 1, + 'exactly one line for the skill', + ); +}); + +test('skillIndexBlock: empty or all-disabled input renders nothing (issue #120)', () => { + assert.equal(skillIndexBlock([]), ''); + assert.equal( + skillIndexBlock([skill({ name: 'z', extra: { 'disable-model-invocation': 'true' } })]), + '', + ); +}); + +test('skillTool: a valid name returns the skill body and path, rendered as plain text (issue #120)', () => { + const t = skillTool([ + skill({ + name: 'triage', + body: 'read logs bottom-up', + path: '/r/.claude/skills/triage/SKILL.md', + }), + ]); + const out = runExec(t, { skill: 'triage' }); + assert.equal(out.ok, true); + if (!out.ok) return; + assert.equal(out.body, 'read logs bottom-up'); + assert.equal(out.path, '/r/.claude/skills/triage/SKILL.md'); + const text = textOf(t, { skill: 'triage' }, out); + assert.match(text, /^Skill: triage/); + assert.match(text, /read logs bottom-up/); + assert.ok(text.includes('/r/.claude/skills/triage/SKILL.md'), 'result carries the path'); + assert.match(text, /read them on demand with the Read tool/); +}); + +test('skillTool: an unknown name returns an error naming the available skills — never throws (issue #120)', () => { + const t = skillTool([skill({ name: 'a' }), skill({ name: 'b' })]); + const out = runExec(t, { skill: 'nope' }); + assert.equal(out.ok, false); + if (out.ok) return; + assert.match(out.error, /Unknown skill "nope"/); + assert.match(out.error, /Available skills: a, b/); + assert.equal( + textOf(t, { skill: 'nope' }, out), + out.error, + 'the error text is what the model sees', + ); +}); + +test('skillTool: a disabled skill is not in the matchable set (issue #120)', () => { + const t = skillTool([skill({ name: 'secret', extra: { 'disable-model-invocation': 'true' } })]); + const out = runExec(t, { skill: 'secret' }); + assert.equal(out.ok, false); + if (out.ok) return; + assert.match(out.error, /Available skills: \(none available\)/); +}); + +test('skillTool: args are echoed into the result and its rendering (issue #120)', () => { + const t = skillTool([skill({ name: 'triage', body: 'B' })]); + const input = { skill: 'triage', args: 'job=e2e' }; + const out = runExec(t, input); + assert.equal(out.ok, true); + if (!out.ok) return; + assert.equal(out.args, 'job=e2e'); + assert.match(textOf(t, input, out), /Arguments: job=e2e/); +}); diff --git a/packages/ai-claude-compat/src/skill-tool.ts b/packages/ai-claude-compat/src/skill-tool.ts new file mode 100644 index 0000000..43d19bb --- /dev/null +++ b/packages/ai-claude-compat/src/skill-tool.ts @@ -0,0 +1,94 @@ +// Model-facing surface for skills (issue #120). Two builders turn discovered `SkillDefinition`s +// (skills-loader.ts) into progressive disclosure: +// +// - `skillIndexBlock` renders the always-visible tier — one `: ` line per skill +// plus the invocation contract — for the system prompt. Bodies never appear here. +// - `skillTool` is the AI-SDK tool a subagent calls to pull a skill's body into context on demand +// (the second tier); the body then directs on-demand reads of sibling `references/*` (the third). +// +// Both exclude skills whose `disable-model-invocation` frontmatter is `true`. Neither mounts anything +// itself — the caller passes the `skills` array, deciding what a given role may see (target-repo +// skills are untrusted and Worker-only; that wiring is a separate aitm-side issue). + +import { type Tool, tool } from 'ai'; +import { z } from 'zod'; +import { asString } from './frontmatter.ts'; +import type { SkillDefinition } from './skills-loader.ts'; + +// Rendered as the index block's preamble: the blocking contract the model must follow. +export const SKILL_INVOCATION_CONTRACT = + "Skills extend you with task-specific procedures. Each line below is `: `. When a skill's description matches the task at hand, you MUST call the `Skill` tool with that exact name and follow the instructions it returns before producing your answer — this is blocking, not optional. Never claim to have applied a skill without calling the tool, and only invoke names that appear in this list."; + +const skillInputSchema = z.object({ + skill: z.string().min(1).describe('The exact name of a skill from the skills index.'), + args: z + .string() + .optional() + .describe('Free-form input for the skill; echoed back in the result for its instructions.'), +}); +export type SkillToolInput = z.infer; +export type SkillToolOutput = + | { ok: true; name: string; path: string; body: string; args?: string } + | { ok: false; error: string }; + +// A skill is model-invocable unless it opts out with `disable-model-invocation: true`. +function isModelInvocable(skill: SkillDefinition): boolean { + return asString(skill.extra['disable-model-invocation']).trim() !== 'true'; +} + +const oneLine = (s: string): string => s.replace(/[\r\n]+/g, ' ').trim(); + +// The always-visible index tier: the invocation contract plus one line per model-invocable skill. +// Returns '' when nothing is invocable, so a caller can drop the block entirely. +export function skillIndexBlock(skills: readonly SkillDefinition[]): string { + const invocable = skills.filter(isModelInvocable); + if (invocable.length === 0) return ''; + const lines = invocable.map((s) => `${s.name}: ${oneLine(s.description)}`); + return [SKILL_INVOCATION_CONTRACT, '', ...lines, ''].join('\n'); +} + +// The `Skill` tool: exact-name lookup over the model-invocable set. A hit returns the skill's body +// and absolute path; a miss returns an error result naming the available skills (never throws). +export function skillTool( + skills: readonly SkillDefinition[], +): Tool { + const invocable = skills.filter(isModelInvocable); + const byName = new Map(invocable.map((s) => [s.name, s])); + const available = invocable.map((s) => s.name); + return tool({ + description: + 'Load a skill by name to pull its full instructions into context, then follow them before answering. Only names present in the skills index are valid. `args` is optional free-form text passed through to the skill.', + inputSchema: skillInputSchema, + execute: (input: SkillToolInput): SkillToolOutput => { + const skill = byName.get(input.skill); + if (skill === undefined) { + const names = available.length > 0 ? available.join(', ') : '(none available)'; + return { ok: false, error: `Unknown skill "${input.skill}". Available skills: ${names}.` }; + } + return { + ok: true, + name: skill.name, + path: skill.path, + body: skill.body, + ...(input.args !== undefined ? { args: input.args } : {}), + }; + }, + toModelOutput: ({ output }) => ({ type: 'text', value: renderSkillResult(output) }), + }); +} + +// Plain-text rendering so the body reads as instructions, not a JSON-escaped blob (issue #127). +function renderSkillResult(output: SkillToolOutput): string { + if (!output.ok) return output.error; + const header = + output.args !== undefined && output.args !== '' + ? `Skill: ${output.name}\nArguments: ${output.args}` + : `Skill: ${output.name}`; + return [ + header, + '', + output.body, + '', + `Files this skill references (e.g. references/*.md) live next to ${output.path}; read them on demand with the Read tool.`, + ].join('\n'); +} diff --git a/packages/ai-claude-compat/src/skills-loader.test.ts b/packages/ai-claude-compat/src/skills-loader.test.ts index 4b4bed6..a7aa239 100644 --- a/packages/ai-claude-compat/src/skills-loader.test.ts +++ b/packages/ai-claude-compat/src/skills-loader.test.ts @@ -77,3 +77,37 @@ test('loadSkills: missing skills dir yields []', async () => { await dir.cleanup(); } }); + +test('loadSkills: carries unrecognized keys in extra and round-trips a folded description (issue #120)', async () => { + const dir = await tempDir(); + try { + const claude = join(dir.path, '.claude'); + await writeSkill( + claude, + 'triage', + [ + '---', + 'name: triage', + 'description: >-', + ' Use when a CI job fails and you need to', + ' classify the failure before editing.', + 'allowed-tools: [Read, Bash]', + 'disable-model-invocation: false', + '---', + 'read the logs bottom-up', + '', + ].join('\n'), + ); + const [skill] = await loadSkills(claude); + assert.equal( + skill?.description, + 'Use when a CI job fails and you need to classify the failure before editing.', + ); + assert.deepEqual(skill?.extra['allowed-tools'], ['Read', 'Bash']); + assert.equal(skill?.extra['disable-model-invocation'], 'false'); + assert.equal(skill?.extra.name, undefined, 'name is not duplicated into extra'); + assert.equal(skill?.extra.description, undefined, 'description is not duplicated into extra'); + } finally { + await dir.cleanup(); + } +}); diff --git a/packages/ai-claude-compat/src/skills-loader.ts b/packages/ai-claude-compat/src/skills-loader.ts index cc8040e..9aa665e 100644 --- a/packages/ai-claude-compat/src/skills-loader.ts +++ b/packages/ai-claude-compat/src/skills-loader.ts @@ -4,7 +4,7 @@ import { readdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { asString, parseFrontmatter } from './frontmatter.ts'; +import { asString, type FrontmatterValue, parseFrontmatter } from './frontmatter.ts'; export type SkillDefinition = { name: string; @@ -13,6 +13,9 @@ export type SkillDefinition = { body: string; // Absolute path to the SKILL.md, for reading sibling files the skill references. path: string; + // Every frontmatter key other than name/description (e.g. `allowed-tools`, + // `disable-model-invocation`), so callers can honor them (issue #120). + extra: Record; }; // Load every skill under `/skills/*/SKILL.md`. `claudeDir` is a `.claude` directory. @@ -29,11 +32,16 @@ export async function loadSkills(claudeDir: string): Promise const content = await readFile(path, 'utf8').catch(() => null); if (content === null) continue; const { data, body } = parseFrontmatter(content); + const extra: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (key !== 'name' && key !== 'description') extra[key] = value; + } skills.push({ name: asString(data.name) || entry.name, description: asString(data.description), body: body.trim(), path, + extra, }); } skills.sort((a, b) => a.name.localeCompare(b.name)); From 8185287db00f4b4fcfd9365bae08f16639783ef9 Mon Sep 17 00:00:00 2001 From: marshall Date: Mon, 13 Jul 2026 10:34:27 +0000 Subject: [PATCH 2/2] fix(compat): CRLF-safe frontmatter parsing + drop unsupported `+` chomping (#120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CR review on PR #172: - Major: CRLF line endings leaked `\r` into block-scalar values. Root cause is broader than the block scalar — `raw.split('\n')` left a trailing `\r` on every line, and the key-line regex `(.*)$` never matches a line ending in `\r`, so on a CRLF-authored SKILL.md NO field parsed at all. Fixed generally by splitting on `/\r?\n/` in parseBlock, which neutralizes CR for every value path. - Minor: `+` (keep) chomping was accepted by BLOCK_SCALAR but behaved like clip. Dropped it from the regex so `>+` falls through to the plain-scalar path; corrected the comment to document `-`-only support. Tests: CRLF folded/literal round-trips + `>+` plain-scalar fallback. --- .../ai-claude-compat/src/frontmatter.test.ts | 17 +++++++++++++++++ packages/ai-claude-compat/src/frontmatter.ts | 14 ++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/ai-claude-compat/src/frontmatter.test.ts b/packages/ai-claude-compat/src/frontmatter.test.ts index f90f770..a2f2061 100644 --- a/packages/ai-claude-compat/src/frontmatter.test.ts +++ b/packages/ai-claude-compat/src/frontmatter.test.ts @@ -113,3 +113,20 @@ test('parseFrontmatter: `key: >text` on one line stays a plain scalar, not a blo const { data } = parseFrontmatter('---\narrow: >text here\n---\nb'); assert.equal(data.arrow, '>text here'); }); + +test('parseFrontmatter: a folded block scalar strips CRLF carriage returns from every line', () => { + const { data } = parseFrontmatter( + '---\r\ndescription: >-\r\n line one\r\n line two\r\n---\r\nbody\r\n', + ); + assert.equal(data.description, 'line one line two', 'no stray \\r in the folded value'); +}); + +test('parseFrontmatter: a literal block scalar strips CRLF carriage returns from every line', () => { + const { data } = parseFrontmatter('---\r\nsteps: |-\r\n a\r\n b\r\n---\r\nbody\r\n'); + assert.equal(data.steps, 'a\nb', 'interior newline preserved, no \\r'); +}); + +test('parseFrontmatter: `>+` (keep) is unsupported — it stays a plain scalar, not a block header', () => { + const { data } = parseFrontmatter('---\ndesc: >+\n content\n---\nb'); + assert.equal(data.desc, '>+', 'the header does not match; the value is the literal marker'); +}); diff --git a/packages/ai-claude-compat/src/frontmatter.ts b/packages/ai-claude-compat/src/frontmatter.ts index 90a76c4..0f853b2 100644 --- a/packages/ai-claude-compat/src/frontmatter.ts +++ b/packages/ai-claude-compat/src/frontmatter.ts @@ -19,7 +19,10 @@ export function parseFrontmatter(content: string): Frontmatter { function parseBlock(raw: string): Record { const data: Record = {}; - const lines = raw.split('\n'); + // Split on CRLF or LF so a Windows-authored / CRLF-checked-out SKILL.md doesn't leave a trailing + // `\r` on every line — it would fail the key-line match outright and, for block scalars (whose + // value skips the usual `.trim()`), embed `\r` inside the parsed text. + const lines = raw.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i] ?? ''; if (line.trim() === '' || line.trimStart().startsWith('#')) continue; @@ -62,9 +65,12 @@ function parseBlock(raw: string): Record { return data; } -// A block-scalar header is a lone `|` or `>` (literal / folded) with an optional `+`/`-` chomping -// indicator. `key: >text` is a plain scalar, not a block — the header must stand alone. -const BLOCK_SCALAR = /^([|>])([+-]?)$/; +// A block-scalar header is a lone `|` or `>` (literal / folded) with an optional `-` (strip) +// chomping indicator. `key: >text` is a plain scalar, not a block — the header must stand alone. +// `+` (keep) is intentionally unsupported: `>+` fails this match and falls through to the plain +// scalar path rather than being silently treated as clip (its behavior can't be produced here, since +// trailing blank lines are always popped below). +const BLOCK_SCALAR = /^([|>])(-?)$/; // Consume the indented lines following a `key: |` / `key: >` header into a single string. `folded` // (`>`) joins lines with spaces and blank lines with newlines; literal (`|`) keeps every newline.