From 51f03970be62afada29ec6f56b82e72d870c9b3f Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 15 Jun 2026 19:16:26 -0400 Subject: [PATCH 01/14] Add FrontmatterField/SkillField + kind registry; extract CommandField Foundation for skill-as-markdown-frontmatter (CS-11545): every .md stays a MarkdownDef and skill-ness is a polymorphic field, not a SkillDef subclass. - Extract CommandField into its own module so SkillField can reuse it without depending on the legacy Skill card (skill.gts re-exports for back-compat). - FrontmatterField (base) + SkillField (name/description/commands) subclass. - frontmatter-kinds.ts: the kind -> FieldDef registry, kept above MarkdownDef. - Add `yaml` dep to @cardstack/base for frontmatter parsing. Co-Authored-By: Claude Opus 4.8 --- packages/base/command-field.gts | 124 ++++++++++++++++++++++++++++ packages/base/frontmatter-field.gts | 27 ++++++ packages/base/frontmatter-kinds.ts | 25 ++++++ packages/base/package.json | 3 +- packages/base/skill-field.gts | 29 +++++++ packages/base/skill.gts | 119 ++------------------------ pnpm-lock.yaml | 17 +++- 7 files changed, 228 insertions(+), 116 deletions(-) create mode 100644 packages/base/command-field.gts create mode 100644 packages/base/frontmatter-field.gts create mode 100644 packages/base/frontmatter-kinds.ts create mode 100644 packages/base/skill-field.gts diff --git a/packages/base/command-field.gts b/packages/base/command-field.gts new file mode 100644 index 00000000000..77a4fa47557 --- /dev/null +++ b/packages/base/command-field.gts @@ -0,0 +1,124 @@ +import { + Component, + FieldDef, + field, + contains, + relativeTo, + virtualNetworkFor, +} from './card-api'; +import BooleanField from './boolean'; +import { AbsoluteCodeRefField } from './code-ref'; +import StringField from './string'; +import CommandIcon from '@cardstack/boxel-icons/square-chevron-right'; +import { Pill } from '@cardstack/boxel-ui/components'; +import { buildCommandFunctionName } from '@cardstack/runtime-common'; + +// A single command attached to a skill: an absolute code reference plus the +// approval policy the host applies before invoking it. Shared by the legacy +// `Skill` card (`commands`) and by `SkillField` (skill markdown frontmatter). +export class CommandField extends FieldDef { + static displayName = 'CommandField'; + static icon = CommandIcon; + + @field cardTitle = contains(StringField, { + computeVia: function (this: CommandField) { + let moduleRef = this.codeRef?.module; + if (!moduleRef) { + return 'Untitled Command'; + } + let nameSegment = moduleRef.split('/').pop(); + let formattedName = nameSegment + ?.split(/[-_]/g) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + return formattedName; + }, + }); + + @field codeRef = contains(AbsoluteCodeRefField, { + description: 'An absolute code reference to the command to be executed', + }); + @field requiresApproval = contains(BooleanField, { + description: + 'If true, this command will require human approval before it is executed in the host.', + }); + + @field functionName = contains(StringField, { + description: 'The name of the function to be executed', + computeVia: function (this: CommandField) { + let vn = virtualNetworkFor(this); + if (!vn) { + throw new Error( + `CommandField.functionName requires a VirtualNetwork (no store attached to ${this.constructor.name})`, + ); + } + return buildCommandFunctionName( + this.codeRef, + this[Symbol.for('cardstack-relative-to') as typeof relativeTo], + vn, + ); + }, + }); + + static embedded = class Embedded extends Component { + + }; +} diff --git a/packages/base/frontmatter-field.gts b/packages/base/frontmatter-field.gts new file mode 100644 index 00000000000..f33d7fdbcd4 --- /dev/null +++ b/packages/base/frontmatter-field.gts @@ -0,0 +1,27 @@ +import { + Component, + FieldDef, + field, + contains, + type BaseDefComponent, +} from './card-api'; +import StringField from './string'; + +// Base type for the value of `MarkdownDef.frontmatter`. A markdown file's YAML +// frontmatter is parsed into a `FrontmatterField`; when the frontmatter +// declares a recognized `kind` (e.g. `kind: skill`), the concrete instance is a +// subclass (e.g. `SkillField`) selected by the kind registry. Plain markdown +// with no recognized kind stays a base `FrontmatterField`. +export class FrontmatterField extends FieldDef { + static displayName = 'Frontmatter'; + + @field kind = contains(StringField); + + static embedded: BaseDefComponent = class Embedded extends Component< + typeof this + > { + + }; +} diff --git a/packages/base/frontmatter-kinds.ts b/packages/base/frontmatter-kinds.ts new file mode 100644 index 00000000000..69cf7319012 --- /dev/null +++ b/packages/base/frontmatter-kinds.ts @@ -0,0 +1,25 @@ +import { FrontmatterField } from './frontmatter-field'; +import { SkillField } from './skill-field'; + +// Maps a frontmatter `kind` to the FieldDef that models it. This registry lives +// above `MarkdownDef` so the base markdown type stays ignorant of its kinds: +// adding a new kind (e.g. `recipe`, `persona`) is a new field type + an entry +// here, not a new FileDef subclass or extension rule. +const FRONTMATTER_FIELD_BY_KIND: Record = { + skill: SkillField, +}; + +export function frontmatterFieldForKind( + kind: string | undefined, +): typeof FrontmatterField { + if (kind && Object.prototype.hasOwnProperty.call(FRONTMATTER_FIELD_BY_KIND, kind)) { + return FRONTMATTER_FIELD_BY_KIND[kind]; + } + return FrontmatterField; +} + +export function isKnownFrontmatterKind(kind: string | undefined): boolean { + return ( + !!kind && Object.prototype.hasOwnProperty.call(FRONTMATTER_FIELD_BY_KIND, kind) + ); +} diff --git a/packages/base/package.json b/packages/base/package.json index cbf47832d38..875e69e23a6 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -24,7 +24,8 @@ "matrix-js-sdk": "catalog:", "super-fast-md5": "catalog:", "@floating-ui/dom": "catalog:", - "tracked-built-ins": "catalog:" + "tracked-built-ins": "catalog:", + "yaml": "catalog:" }, "peerDependencies": { "ember-provide-consume-context": "^0.7.0", diff --git a/packages/base/skill-field.gts b/packages/base/skill-field.gts new file mode 100644 index 00000000000..eaf4c57f768 --- /dev/null +++ b/packages/base/skill-field.gts @@ -0,0 +1,29 @@ +import { + Component, + field, + contains, + containsMany, + type BaseDefComponent, +} from './card-api'; +import StringField from './string'; +import { FrontmatterField } from './frontmatter-field'; +import { CommandField } from './command-field'; + +// The frontmatter of a skill markdown file (`kind: skill`). Mirrors the field +// shape of the legacy `Skill` card so the host's command-definition upload flow +// reads `markdownDef.frontmatter.commands` exactly as it reads `Skill.commands`. +export class SkillField extends FrontmatterField { + static displayName = 'Skill'; + + @field name = contains(StringField); + @field description = contains(StringField); + @field commands = containsMany(CommandField); + + static embedded: BaseDefComponent = class Embedded extends Component< + typeof this + > { + + }; +} diff --git a/packages/base/skill.gts b/packages/base/skill.gts index 0d87ab62624..d1afe2d482d 100644 --- a/packages/base/skill.gts +++ b/packages/base/skill.gts @@ -1,131 +1,22 @@ import { CardDef, Component, - FieldDef, field, contains, containsMany, - relativeTo, - virtualNetworkFor, type BaseDefComponent, } from './card-api'; -import BooleanField from './boolean'; -import { AbsoluteCodeRefField } from './code-ref'; import MarkdownField from './markdown'; import StringField from './string'; import RobotIcon from '@cardstack/boxel-icons/robot'; -import CommandIcon from '@cardstack/boxel-icons/square-chevron-right'; -import { Pill } from '@cardstack/boxel-ui/components'; -import { buildCommandFunctionName } from '@cardstack/runtime-common'; +import { CommandField } from './command-field'; export const isSkillCard = Symbol.for('is-skill-card'); -export class CommandField extends FieldDef { - static displayName = 'CommandField'; - static icon = CommandIcon; - - @field cardTitle = contains(StringField, { - computeVia: function (this: CommandField) { - let moduleRef = this.codeRef?.module; - if (!moduleRef) { - return 'Untitled Command'; - } - let nameSegment = moduleRef.split('/').pop(); - let formattedName = nameSegment - ?.split(/[-_]/g) - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(' '); - return formattedName; - }, - }); - - @field codeRef = contains(AbsoluteCodeRefField, { - description: 'An absolute code reference to the command to be executed', - }); - @field requiresApproval = contains(BooleanField, { - description: - 'If true, this command will require human approval before it is executed in the host.', - }); - - @field functionName = contains(StringField, { - description: 'The name of the function to be executed', - computeVia: function (this: CommandField) { - let vn = virtualNetworkFor(this); - if (!vn) { - throw new Error( - `CommandField.functionName requires a VirtualNetwork (no store attached to ${this.constructor.name})`, - ); - } - return buildCommandFunctionName( - this.codeRef, - this[Symbol.for('cardstack-relative-to') as typeof relativeTo], - vn, - ); - }, - }); - - static embedded = class Embedded extends Component { - - }; -} +// Re-exported for back-compat: `CommandField` now lives in its own module so +// `SkillField` (skill markdown frontmatter) can reuse it without depending on +// the legacy `Skill` card. +export { CommandField }; export class Skill extends CardDef { static displayName = 'Skill'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cfe284bc085..21903b1b969 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -954,6 +954,9 @@ importers: tracked-built-ins: specifier: ^4.1.2 version: 4.1.2(@babel/core@7.29.0) + yaml: + specifier: 'catalog:' + version: 2.9.0 packages/billing: dependencies: @@ -10250,7 +10253,7 @@ packages: optional: true eslint-plugin-qunit-dom@https://codeload.github.com/mainmatter/eslint-plugin-qunit-dom/tar.gz/d66c84190b018deb96c715c7f2bde3536a9c704b: - resolution: {gitHosted: true, tarball: https://codeload.github.com/mainmatter/eslint-plugin-qunit-dom/tar.gz/d66c84190b018deb96c715c7f2bde3536a9c704b} + resolution: {gitHosted: true, integrity: sha512-jRZYHAvECM5EM8ypI2sy7TJzr5B9auRsrUTghrj26o+pXdd0ElHw1ORdKDpICuRFpuGvoT748FVmOpaqswLd1g==, tarball: https://codeload.github.com/mainmatter/eslint-plugin-qunit-dom/tar.gz/d66c84190b018deb96c715c7f2bde3536a9c704b} version: 0.2.0 engines: {node: 12.* || 14.* || >= 16.*} peerDependencies: @@ -19129,7 +19132,10 @@ snapshots: dependencies: '@percy/cli-command': 1.31.13(typescript@5.9.3) transitivePeerDependencies: + - bufferutil + - supports-color - typescript + - utf-8-validate '@percy/cli-command@1.31.13(typescript@5.9.3)': dependencies: @@ -19146,7 +19152,10 @@ snapshots: dependencies: '@percy/cli-command': 1.31.13(typescript@5.9.3) transitivePeerDependencies: + - bufferutil + - supports-color - typescript + - utf-8-validate '@percy/cli-doctor@1.31.13(typescript@5.9.3)': dependencies: @@ -19182,7 +19191,10 @@ snapshots: '@percy/cli-command': 1.31.13(typescript@5.9.3) yaml: 2.9.0 transitivePeerDependencies: + - bufferutil + - supports-color - typescript + - utf-8-validate '@percy/cli-upload@1.31.13(typescript@5.9.3)': dependencies: @@ -19190,7 +19202,10 @@ snapshots: fast-glob: 3.3.3 image-size: 1.2.1 transitivePeerDependencies: + - bufferutil + - supports-color - typescript + - utf-8-validate '@percy/cli@1.31.13(typescript@5.9.3)': dependencies: From e232dcdd3eefdf7979ad21cad2f2e0433b020f30 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 15 Jun 2026 19:20:14 -0400 Subject: [PATCH 02/14] Parse markdown frontmatter into a polymorphic MarkdownDef.frontmatter field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MarkdownDef now parses YAML frontmatter and, when `kind` maps to a known frontmatter subclass (e.g. `kind: skill` -> SkillField), populates the nested `frontmatter` field and records the concrete subclass so it rehydrates as that type. Flat `kind`/`description` are also written for search. Closes the FileDef write-path gap from the CS-11568 spike: extractAttributes routes per-field meta via a global symbol; the file extractor lifts it into the resource's `meta.fields` (keeping it out of the flat search_doc) and buildFileResource threads it through. Additive — FileDefs that emit no field meta are unchanged. Co-Authored-By: Claude Opus 4.8 --- packages/base/frontmatter-parse.ts | 32 ++++++++ packages/base/markdown-file-def.gts | 81 ++++++++++++++++++- .../utils/file-def-attributes-extractor.ts | 22 ++++- 3 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 packages/base/frontmatter-parse.ts diff --git a/packages/base/frontmatter-parse.ts b/packages/base/frontmatter-parse.ts new file mode 100644 index 00000000000..7f7c968e721 --- /dev/null +++ b/packages/base/frontmatter-parse.ts @@ -0,0 +1,32 @@ +import { parse as parseYaml } from 'yaml'; + +export interface ParsedFrontmatter { + // The parsed YAML frontmatter as a plain object (empty when the file has no + // frontmatter block). + data: Record; + // The markdown body with the leading frontmatter block removed. + body: string; +} + +// Matches a leading YAML frontmatter block delimited by `---` fences at the +// very start of the file: `---\n\n---\n`. +const FRONTMATTER_RE = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/; + +// Parse a leading `--- … ---` YAML frontmatter block. Throws if the block is +// present but contains invalid YAML, so callers can surface the parse failure +// rather than silently dropping it. +export function parseFrontmatter(markdown: string): ParsedFrontmatter { + if (!markdown.startsWith('---')) { + return { data: {}, body: markdown }; + } + let match = FRONTMATTER_RE.exec(markdown); + if (!match) { + return { data: {}, body: markdown }; + } + let parsed = parseYaml(match[1]); + let data = + parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + return { data, body: markdown.slice(match[0].length) }; +} diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index 45c6b6504dd..bd5ff036334 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -1,6 +1,7 @@ import { byteStreamToUint8Array, extractCardReferenceUrls, + identifyCard, VirtualNetwork, } from '@cardstack/runtime-common'; import MarkdownIcon from '@cardstack/boxel-icons/align-box-left-middle'; @@ -22,6 +23,18 @@ import { type ByteStream, type SerializedFile, } from './file-api'; +import { FrontmatterField } from './frontmatter-field'; +import { + frontmatterFieldForKind, + isKnownFrontmatterKind, +} from './frontmatter-kinds'; +import { parseFrontmatter } from './frontmatter-parse'; + +// Channel for routing per-field meta (e.g. the concrete subclass of a +// polymorphic field) from `extractAttributes` to the index resource builder, +// without it leaking into the flat `search_doc`. The host file extractor reads +// the same global symbol. See `file-def-attributes-extractor.ts`. +const fileFieldMetaSymbol = Symbol.for('boxel:file-field-meta'); const MARKDOWN_EXTENSIONS = new Set(['.md', '.markdown']); const EXCERPT_MAX_LENGTH = 500; @@ -447,6 +460,13 @@ export class MarkdownDef extends FileDef { @field excerpt = contains(StringField); @field content = contains(StringField); + // The file's YAML frontmatter. When the frontmatter declares a recognized + // `kind` (e.g. `kind: skill`), this holds the matching subclass instance + // (e.g. `SkillField`); otherwise it stays a base `FrontmatterField`. The + // concrete subclass is recorded in `meta.fields.frontmatter.adoptsFrom` by + // `extractAttributes`, so it rehydrates as the right type on read. + @field frontmatter = contains(FrontmatterField); + @field cardReferenceUrls = containsMany(StringField, { computeVia: function (this: MarkdownDef) { if (!this.content) { @@ -496,6 +516,14 @@ export class MarkdownDef extends FileDef { excerpt: string; content: string; cardReferenceUrls: string[]; + // Flat, searchable frontmatter projection (e.g. `kind: 'skill'` → + // `searchFiles({ filter: { eq: { kind: 'skill' } } })`). `name` is NOT + // surfaced flat because it collides with the FileDef's filename; the + // skill name lives in `frontmatter.name`. + kind?: string; + description?: string; + // The nested frontmatter field value, typed by `kind` via the registry. + frontmatter?: Record; }> > { let extension = getExtension(url); @@ -516,10 +544,31 @@ export class MarkdownDef extends FileDef { let markdown = new TextDecoder().decode(bytes); let fallbackTitle = fileNameWithoutExtension(base.name ?? ''); - return { + let frontmatterData: Record = {}; + let body = markdown; + try { + let parsed = parseFrontmatter(normalizeMarkdown(markdown)); + frontmatterData = parsed.data; + body = parsed.body; + } catch (err) { + // Invalid YAML: index the markdown without frontmatter rather than fail + // the whole file. TODO(CS-11545): surface this via indexing diagnostics + // instead of only a console warning (spec Risks — visible parse errors). + console.warn(`[markdown-file-def] frontmatter parse failed for ${url}:`, err); + } + + let attributes: SerializedFile<{ + title: string; + excerpt: string; + content: string; + cardReferenceUrls: string[]; + kind?: string; + description?: string; + frontmatter?: Record; + }> = { ...base, - title: extractTitle(markdown, fallbackTitle), - excerpt: extractExcerpt(markdown), + title: extractTitle(body, fallbackTitle), + excerpt: extractExcerpt(body), content: markdown, cardReferenceUrls: extractCardReferenceUrls( markdown, @@ -527,5 +576,31 @@ export class MarkdownDef extends FileDef { new VirtualNetwork(), ), }; + + let kind = + typeof frontmatterData.kind === 'string' + ? frontmatterData.kind + : undefined; + if (kind !== undefined) { + attributes.kind = kind; // flat, searchable + } + + // When the kind maps to a known frontmatter subclass, carry the nested + // field value and record the concrete subclass so it rehydrates as that + // type. The base (declared) FrontmatterField needs no override. + if (isKnownFrontmatterKind(kind)) { + attributes.frontmatter = frontmatterData; + if (typeof frontmatterData.description === 'string') { + attributes.description = frontmatterData.description; // flat, searchable + } + let adoptsFrom = identifyCard(frontmatterFieldForKind(kind)); + if (adoptsFrom) { + (attributes as Record)[fileFieldMetaSymbol] = { + frontmatter: { adoptsFrom }, + }; + } + } + + return attributes; } } diff --git a/packages/host/app/utils/file-def-attributes-extractor.ts b/packages/host/app/utils/file-def-attributes-extractor.ts index a3700717e27..d1e38b2b670 100644 --- a/packages/host/app/utils/file-def-attributes-extractor.ts +++ b/packages/host/app/utils/file-def-attributes-extractor.ts @@ -200,14 +200,27 @@ export class FileDefAttributesExtractor { let displayNames = getDisplayNames(klass); let adoptsFrom = typeCodeRefs[0] ?? this.#fileDefCodeRef; let queryFieldDefs = await this.extractQueryFieldDefs(klass); + // `extractAttributes` may route per-field meta (the concrete subclass + // of a nested polymorphic field) via this global symbol. Lift it out so + // it lands in the resource's `meta.fields` and never in the flat + // `search_doc`. The base FileDef sets the same `Symbol.for` key. + let fieldMetaSymbol = Symbol.for('boxel:file-field-meta'); + let cleanedDoc: Record = { ...searchDoc }; + let fieldsMeta = cleanedDoc[fieldMetaSymbol] as + | NonNullable + | undefined; + if (fieldsMeta) { + delete cleanedDoc[fieldMetaSymbol]; + } return { status: 'ready', - searchDoc, + searchDoc: cleanedDoc, resource: buildFileResource( this.#fileURL, - searchDoc, + cleanedDoc, adoptsFrom, queryFieldDefs, + fieldsMeta, ), types, displayNames, @@ -401,6 +414,7 @@ export function buildFileResource( attributes: Record, adoptsFrom: CodeRef, queryFieldDefs?: Record, + fieldsMeta?: NonNullable, ): FileMetaResource { let name = new URL(fileURL).pathname.split('/').pop() ?? fileURL; let baseAttributes = { @@ -421,6 +435,10 @@ export function buildFileResource( attributes: mergedAttributes, meta: { adoptsFrom, + // Per-field subclass overrides for nested polymorphic fields (e.g. + // `frontmatter` → SkillField). Without this the field rehydrates as its + // declared base type. Supplied by `extractAttributes` (see below). + ...(fieldsMeta ? { fields: fieldsMeta } : {}), ...(queryFieldDefs ? { queryFieldDefs } : {}), }, links: { self: fileURL }, From e0c86118536c06590d7340f1b617491bcb908d1b Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Mon, 15 Jun 2026 19:22:01 -0400 Subject: [PATCH 03/14] Test markdown skill frontmatter round-trip Covers parseFrontmatter, MarkdownDef.extractAttributes (flat kind + nested frontmatter + routed SkillField marker), the write->read round-trip rehydrating frontmatter as SkillField with commands, and plain-markdown no-op. Requires the host test-services stack / CI to run. Co-Authored-By: Claude Opus 4.8 --- .../markdown-skill-frontmatter-test.gts | 207 ++++++++++++++++++ 1 file changed, 207 insertions(+) create mode 100644 packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts diff --git a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts new file mode 100644 index 00000000000..54b8afcdeec --- /dev/null +++ b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts @@ -0,0 +1,207 @@ +// CS-11545 — a skill is markdown with `kind: skill` frontmatter, not a SkillDef +// subclass. These tests exercise the real MarkdownDef + SkillField: +// +// 1. parseFrontmatter pulls the YAML block off the top of the body. +// 2. MarkdownDef.extractAttributes parses frontmatter, writes a flat +// searchable `kind`, carries the nested `frontmatter` value, and routes the +// per-field subclass marker (SkillField) via the file-field-meta symbol. +// 3. The write→read round-trip (extractor lift → buildFileResource → +// createFromSerialized) rehydrates `frontmatter` as a SkillField with its +// commands intact — closing the write-path gap the CS-11568 spike pinned. +// 4. Plain markdown (no frontmatter) is unaffected. +// +// NOTE: requires the host test-services stack / CI to run (local host-test boot +// is documented-broken). Authored against the harness conventions in +// spike-cs-11568-filedef-poly-test.gts. + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { baseRealm, identifyCard } from '@cardstack/runtime-common'; +import type { Loader } from '@cardstack/runtime-common/loader'; + +import { buildFileResource } from '@cardstack/host/utils/file-def-attributes-extractor'; + +import { setupLocalIndexing, testRealmURL } from '../../helpers'; +import { setupBaseRealm, createFromSerialized } from '../../helpers/base-realm'; +import { setupMockMatrix } from '../../helpers/mock-matrix'; +import { setupRenderingTest } from '../../helpers/setup'; + +const FILE_FIELD_META = Symbol.for('boxel:file-field-meta'); + +let loader: Loader; + +// Minimal getStream for extractAttributes: hand back the markdown bytes. +function streamOf(markdown: string): () => Promise { + return async () => new TextEncoder().encode(markdown); +} + +const SKILL_MD = `--- +kind: skill +name: Realm Sync +description: Sync workspace files +commands: + - codeRef: + module: '@cardstack/boxel-host/commands/realm-sync' + name: SyncCommand + requiresApproval: true +--- +# Realm Sync + +Body paragraph. +`; + +module( + 'Integration | CS-11545 | markdown skill frontmatter', + function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + setupMockMatrix(hooks); + + hooks.beforeEach(function () { + loader = getService('loader-service').loader; + }); + + async function loadBase() { + let { MarkdownDef } = await loader.import( + `${baseRealm.url}markdown-file-def`, + ); + let { SkillField } = await loader.import( + `${baseRealm.url}skill-field`, + ); + let { FrontmatterField } = await loader.import( + `${baseRealm.url}frontmatter-field`, + ); + let { parseFrontmatter } = await loader.import( + `${baseRealm.url}frontmatter-parse`, + ); + return { MarkdownDef, SkillField, FrontmatterField, parseFrontmatter }; + } + + test('parseFrontmatter splits the YAML block from the body', async function (assert) { + let { parseFrontmatter } = await loadBase(); + let { data, body } = parseFrontmatter(SKILL_MD); + assert.strictEqual(data.kind, 'skill', 'kind parsed'); + assert.strictEqual(data.name, 'Realm Sync', 'name parsed'); + assert.strictEqual( + (data.commands as any[])[0].codeRef.name, + 'SyncCommand', + 'nested command codeRef parsed', + ); + assert.true( + body.startsWith('# Realm Sync'), + 'body excludes the frontmatter block', + ); + + let plain = parseFrontmatter('# Just markdown\n\nNo frontmatter.'); + assert.deepEqual(plain.data, {}, 'no frontmatter -> empty data'); + assert.strictEqual( + plain.body, + '# Just markdown\n\nNo frontmatter.', + 'no frontmatter -> body verbatim', + ); + }); + + test('extractAttributes surfaces flat kind, nested frontmatter, and routes the SkillField marker', async function (assert) { + let { MarkdownDef, SkillField } = await loadBase(); + let url = `${testRealmURL}skills/realm-sync/SKILL.md`; + let attrs = await MarkdownDef.extractAttributes(url, streamOf(SKILL_MD), {}); + + assert.strictEqual(attrs.kind, 'skill', 'flat searchable kind written'); + assert.strictEqual( + attrs.description, + 'Sync workspace files', + 'flat searchable description written', + ); + assert.strictEqual( + attrs.frontmatter.name, + 'Realm Sync', + 'nested frontmatter value carried', + ); + assert.strictEqual( + attrs.frontmatter.commands[0].requiresApproval, + true, + 'nested command survives extraction', + ); + + let routed = (attrs as Record)[FILE_FIELD_META]; + assert.deepEqual( + routed?.frontmatter?.adoptsFrom, + identifyCard(SkillField), + 'routed per-field meta points frontmatter at SkillField', + ); + }); + + test('write -> read round-trip rehydrates frontmatter as SkillField with commands', async function (assert) { + let { MarkdownDef, SkillField } = await loadBase(); + let url = `${testRealmURL}skills/realm-sync/SKILL.md`; + let attrs = await MarkdownDef.extractAttributes(url, streamOf(SKILL_MD), {}); + + // Mirror what the file extractor does: lift the routed field meta out of + // the flat searchDoc and thread it into the resource's meta.fields. + let cleaned: Record = { ...attrs }; + let fieldsMeta = cleaned[FILE_FIELD_META]; + delete cleaned[FILE_FIELD_META]; + + let resource = buildFileResource( + url, + cleaned, + identifyCard(MarkdownDef)!, + undefined, + fieldsMeta, + ); + assert.deepEqual( + (resource.meta as any).fields?.frontmatter?.adoptsFrom, + identifyCard(SkillField), + 'buildFileResource now threads meta.fields (gap closed)', + ); + + let instance: any = await createFromSerialized( + resource as any, + { data: resource } as any, + undefined, + ); + assert.true( + instance.frontmatter instanceof SkillField, + 'frontmatter rehydrated as SkillField subclass', + ); + assert.strictEqual( + instance.frontmatter.name, + 'Realm Sync', + 'subclass field survives round-trip', + ); + assert.strictEqual( + instance.frontmatter.commands.length, + 1, + 'commands survive round-trip', + ); + assert.strictEqual( + instance.frontmatter.commands[0].codeRef.name, + 'SyncCommand', + 'command codeRef survives round-trip', + ); + }); + + test('plain markdown (no frontmatter) carries no kind and no routed meta', async function (assert) { + let { MarkdownDef } = await loadBase(); + let url = `${testRealmURL}notes/readme.md`; + let attrs = await MarkdownDef.extractAttributes( + url, + streamOf('# Readme\n\nHello.'), + {}, + ); + assert.strictEqual(attrs.kind, undefined, 'no kind for plain markdown'); + assert.strictEqual( + (attrs as Record)[FILE_FIELD_META], + undefined, + 'no routed field meta for plain markdown', + ); + assert.strictEqual(attrs.title, 'Readme', 'title still extracted'); + assert.true( + attrs.content.includes('Hello.'), + 'content still extracted', + ); + }); + }, +); From 2b913ee74280a1c9f1f3406f9c1f82ced88d4013 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 10:58:59 -0400 Subject: [PATCH 04/14] Namespace boxel-specific frontmatter under a `boxel:` key `kind` at the top level is too generic to gate Boxel behavior. Move the discriminator and boxel-only data under a `boxel:` namespace; shared keys (`name`, `description`) stay top-level so Claude Code's SKILL.md reads the same file. - FrontmatterField -> BoxelFrontmatterField; field renamed MarkdownDef.frontmatter -> MarkdownDef.boxel; registry keys on boxel.kind. - SkillField sources name/description from shared top-level frontmatter; kind + commands from the boxel namespace. Co-Authored-By: Claude Opus 4.8 --- packages/base/boxel-frontmatter-field.gts | 28 ++++++++ packages/base/boxel-frontmatter-kinds.ts | 25 +++++++ packages/base/frontmatter-field.gts | 27 -------- packages/base/frontmatter-kinds.ts | 25 ------- packages/base/markdown-file-def.gts | 67 ++++++++++++------- packages/base/skill-field.gts | 12 ++-- .../utils/file-def-attributes-extractor.ts | 2 +- .../markdown-skill-frontmatter-test.gts | 55 ++++++++------- 8 files changed, 132 insertions(+), 109 deletions(-) create mode 100644 packages/base/boxel-frontmatter-field.gts create mode 100644 packages/base/boxel-frontmatter-kinds.ts delete mode 100644 packages/base/frontmatter-field.gts delete mode 100644 packages/base/frontmatter-kinds.ts diff --git a/packages/base/boxel-frontmatter-field.gts b/packages/base/boxel-frontmatter-field.gts new file mode 100644 index 00000000000..5d457d998f2 --- /dev/null +++ b/packages/base/boxel-frontmatter-field.gts @@ -0,0 +1,28 @@ +import { + Component, + FieldDef, + field, + contains, + type BaseDefComponent, +} from './card-api'; +import StringField from './string'; + +// Base type for the value of `MarkdownDef.boxel` — the `boxel:` namespace of a +// markdown file's YAML frontmatter. Boxel-specific frontmatter is namespaced +// under `boxel:` so generic top-level keys (notably `name`/`description`, which +// are shared with Claude Code's SKILL.md) never trigger Boxel behavior. When the +// namespace declares a recognized `kind` (e.g. `kind: skill`), the concrete +// instance is a subclass (e.g. `SkillField`) selected by the kind registry. +export class BoxelFrontmatterField extends FieldDef { + static displayName = 'Boxel Frontmatter'; + + @field kind = contains(StringField); + + static embedded: BaseDefComponent = class Embedded extends Component< + typeof this + > { + + }; +} diff --git a/packages/base/boxel-frontmatter-kinds.ts b/packages/base/boxel-frontmatter-kinds.ts new file mode 100644 index 00000000000..c3dc0f657a5 --- /dev/null +++ b/packages/base/boxel-frontmatter-kinds.ts @@ -0,0 +1,25 @@ +import { BoxelFrontmatterField } from './boxel-frontmatter-field'; +import { SkillField } from './skill-field'; + +// Maps a `boxel.kind` value to the FieldDef that models it. This registry lives +// above `MarkdownDef` so the base markdown type stays ignorant of its kinds: +// adding a new kind (e.g. `recipe`, `persona`) is a new field type + an entry +// here, not a new FileDef subclass or extension rule. +const BOXEL_FIELD_BY_KIND: Record = { + skill: SkillField, +}; + +export function boxelFieldForKind( + kind: string | undefined, +): typeof BoxelFrontmatterField { + if (kind && Object.prototype.hasOwnProperty.call(BOXEL_FIELD_BY_KIND, kind)) { + return BOXEL_FIELD_BY_KIND[kind]; + } + return BoxelFrontmatterField; +} + +export function isKnownBoxelKind(kind: string | undefined): boolean { + return ( + !!kind && Object.prototype.hasOwnProperty.call(BOXEL_FIELD_BY_KIND, kind) + ); +} diff --git a/packages/base/frontmatter-field.gts b/packages/base/frontmatter-field.gts deleted file mode 100644 index f33d7fdbcd4..00000000000 --- a/packages/base/frontmatter-field.gts +++ /dev/null @@ -1,27 +0,0 @@ -import { - Component, - FieldDef, - field, - contains, - type BaseDefComponent, -} from './card-api'; -import StringField from './string'; - -// Base type for the value of `MarkdownDef.frontmatter`. A markdown file's YAML -// frontmatter is parsed into a `FrontmatterField`; when the frontmatter -// declares a recognized `kind` (e.g. `kind: skill`), the concrete instance is a -// subclass (e.g. `SkillField`) selected by the kind registry. Plain markdown -// with no recognized kind stays a base `FrontmatterField`. -export class FrontmatterField extends FieldDef { - static displayName = 'Frontmatter'; - - @field kind = contains(StringField); - - static embedded: BaseDefComponent = class Embedded extends Component< - typeof this - > { - - }; -} diff --git a/packages/base/frontmatter-kinds.ts b/packages/base/frontmatter-kinds.ts deleted file mode 100644 index 69cf7319012..00000000000 --- a/packages/base/frontmatter-kinds.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { FrontmatterField } from './frontmatter-field'; -import { SkillField } from './skill-field'; - -// Maps a frontmatter `kind` to the FieldDef that models it. This registry lives -// above `MarkdownDef` so the base markdown type stays ignorant of its kinds: -// adding a new kind (e.g. `recipe`, `persona`) is a new field type + an entry -// here, not a new FileDef subclass or extension rule. -const FRONTMATTER_FIELD_BY_KIND: Record = { - skill: SkillField, -}; - -export function frontmatterFieldForKind( - kind: string | undefined, -): typeof FrontmatterField { - if (kind && Object.prototype.hasOwnProperty.call(FRONTMATTER_FIELD_BY_KIND, kind)) { - return FRONTMATTER_FIELD_BY_KIND[kind]; - } - return FrontmatterField; -} - -export function isKnownFrontmatterKind(kind: string | undefined): boolean { - return ( - !!kind && Object.prototype.hasOwnProperty.call(FRONTMATTER_FIELD_BY_KIND, kind) - ); -} diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index bd5ff036334..d3b2de232ae 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -23,11 +23,11 @@ import { type ByteStream, type SerializedFile, } from './file-api'; -import { FrontmatterField } from './frontmatter-field'; +import { BoxelFrontmatterField } from './boxel-frontmatter-field'; import { - frontmatterFieldForKind, - isKnownFrontmatterKind, -} from './frontmatter-kinds'; + boxelFieldForKind, + isKnownBoxelKind, +} from './boxel-frontmatter-kinds'; import { parseFrontmatter } from './frontmatter-parse'; // Channel for routing per-field meta (e.g. the concrete subclass of a @@ -460,12 +460,13 @@ export class MarkdownDef extends FileDef { @field excerpt = contains(StringField); @field content = contains(StringField); - // The file's YAML frontmatter. When the frontmatter declares a recognized - // `kind` (e.g. `kind: skill`), this holds the matching subclass instance - // (e.g. `SkillField`); otherwise it stays a base `FrontmatterField`. The - // concrete subclass is recorded in `meta.fields.frontmatter.adoptsFrom` by - // `extractAttributes`, so it rehydrates as the right type on read. - @field frontmatter = contains(FrontmatterField); + // The `boxel:` namespace of the file's YAML frontmatter. When it declares a + // recognized `kind` (e.g. `boxel.kind: skill`), this holds the matching + // subclass instance (e.g. `SkillField`); otherwise it stays a base + // `BoxelFrontmatterField`. The concrete subclass is recorded in + // `meta.fields.boxel.adoptsFrom` by `extractAttributes`, so it rehydrates as + // the right type on read. + @field boxel = contains(BoxelFrontmatterField); @field cardReferenceUrls = containsMany(StringField, { computeVia: function (this: MarkdownDef) { @@ -516,14 +517,14 @@ export class MarkdownDef extends FileDef { excerpt: string; content: string; cardReferenceUrls: string[]; - // Flat, searchable frontmatter projection (e.g. `kind: 'skill'` → - // `searchFiles({ filter: { eq: { kind: 'skill' } } })`). `name` is NOT - // surfaced flat because it collides with the FileDef's filename; the - // skill name lives in `frontmatter.name`. + // Flat, searchable projection of the boxel namespace (e.g. `kind: 'skill'` + // → `searchFiles({ filter: { eq: { kind: 'skill' } } })`). The skill name + // is NOT surfaced flat because it collides with the FileDef's filename; it + // lives in `boxel.name`. kind?: string; description?: string; - // The nested frontmatter field value, typed by `kind` via the registry. - frontmatter?: Record; + // The nested `boxel` field value, typed by `boxel.kind` via the registry. + boxel?: Record; }> > { let extension = getExtension(url); @@ -564,7 +565,7 @@ export class MarkdownDef extends FileDef { cardReferenceUrls: string[]; kind?: string; description?: string; - frontmatter?: Record; + boxel?: Record; }> = { ...base, title: extractTitle(body, fallbackTitle), @@ -577,26 +578,40 @@ export class MarkdownDef extends FileDef { ), }; + // Boxel-specific frontmatter is namespaced under `boxel:`; generic + // top-level keys (shared with Claude Code) never trigger Boxel behavior. + let boxelNamespace = + frontmatterData.boxel && + typeof frontmatterData.boxel === 'object' && + !Array.isArray(frontmatterData.boxel) + ? (frontmatterData.boxel as Record) + : undefined; let kind = - typeof frontmatterData.kind === 'string' - ? frontmatterData.kind + typeof boxelNamespace?.kind === 'string' + ? boxelNamespace.kind : undefined; if (kind !== undefined) { attributes.kind = kind; // flat, searchable } - // When the kind maps to a known frontmatter subclass, carry the nested - // field value and record the concrete subclass so it rehydrates as that - // type. The base (declared) FrontmatterField needs no override. - if (isKnownFrontmatterKind(kind)) { - attributes.frontmatter = frontmatterData; + // When `boxel.kind` maps to a known subclass, assemble the `boxel` field + // value (kind + commands from the namespace; name/description from the + // shared top-level keys) and record the concrete subclass so it rehydrates + // as that type. The base BoxelFrontmatterField needs no override. + if (isKnownBoxelKind(kind)) { + attributes.boxel = { + kind, + name: frontmatterData.name, + description: frontmatterData.description, + commands: boxelNamespace?.commands, + }; if (typeof frontmatterData.description === 'string') { attributes.description = frontmatterData.description; // flat, searchable } - let adoptsFrom = identifyCard(frontmatterFieldForKind(kind)); + let adoptsFrom = identifyCard(boxelFieldForKind(kind)); if (adoptsFrom) { (attributes as Record)[fileFieldMetaSymbol] = { - frontmatter: { adoptsFrom }, + boxel: { adoptsFrom }, }; } } diff --git a/packages/base/skill-field.gts b/packages/base/skill-field.gts index eaf4c57f768..6ac269d120e 100644 --- a/packages/base/skill-field.gts +++ b/packages/base/skill-field.gts @@ -6,13 +6,15 @@ import { type BaseDefComponent, } from './card-api'; import StringField from './string'; -import { FrontmatterField } from './frontmatter-field'; +import { BoxelFrontmatterField } from './boxel-frontmatter-field'; import { CommandField } from './command-field'; -// The frontmatter of a skill markdown file (`kind: skill`). Mirrors the field -// shape of the legacy `Skill` card so the host's command-definition upload flow -// reads `markdownDef.frontmatter.commands` exactly as it reads `Skill.commands`. -export class SkillField extends FrontmatterField { +// The `boxel:` namespace of a skill markdown file (`boxel.kind: skill`). Mirrors +// the field shape of the legacy `Skill` card so the host's command-definition +// upload flow reads `markdownDef.boxel.commands` exactly as it reads +// `Skill.commands`. `name`/`description` are sourced from the shared top-level +// frontmatter (see `MarkdownDef.extractAttributes`). +export class SkillField extends BoxelFrontmatterField { static displayName = 'Skill'; @field name = contains(StringField); diff --git a/packages/host/app/utils/file-def-attributes-extractor.ts b/packages/host/app/utils/file-def-attributes-extractor.ts index d1e38b2b670..9ee78a21e47 100644 --- a/packages/host/app/utils/file-def-attributes-extractor.ts +++ b/packages/host/app/utils/file-def-attributes-extractor.ts @@ -436,7 +436,7 @@ export function buildFileResource( meta: { adoptsFrom, // Per-field subclass overrides for nested polymorphic fields (e.g. - // `frontmatter` → SkillField). Without this the field rehydrates as its + // `boxel` → SkillField). Without this the field rehydrates as its // declared base type. Supplied by `extractAttributes` (see below). ...(fieldsMeta ? { fields: fieldsMeta } : {}), ...(queryFieldDefs ? { queryFieldDefs } : {}), diff --git a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts index 54b8afcdeec..231c2bbeb32 100644 --- a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts +++ b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts @@ -37,14 +37,15 @@ function streamOf(markdown: string): () => Promise { } const SKILL_MD = `--- -kind: skill name: Realm Sync description: Sync workspace files -commands: - - codeRef: - module: '@cardstack/boxel-host/commands/realm-sync' - name: SyncCommand - requiresApproval: true +boxel: + kind: skill + commands: + - codeRef: + module: '@cardstack/boxel-host/commands/realm-sync' + name: SyncCommand + requiresApproval: true --- # Realm Sync @@ -70,24 +71,28 @@ module( let { SkillField } = await loader.import( `${baseRealm.url}skill-field`, ); - let { FrontmatterField } = await loader.import( - `${baseRealm.url}frontmatter-field`, + let { BoxelFrontmatterField } = await loader.import( + `${baseRealm.url}boxel-frontmatter-field`, ); let { parseFrontmatter } = await loader.import( `${baseRealm.url}frontmatter-parse`, ); - return { MarkdownDef, SkillField, FrontmatterField, parseFrontmatter }; + return { MarkdownDef, SkillField, BoxelFrontmatterField, parseFrontmatter }; } test('parseFrontmatter splits the YAML block from the body', async function (assert) { let { parseFrontmatter } = await loadBase(); let { data, body } = parseFrontmatter(SKILL_MD); - assert.strictEqual(data.kind, 'skill', 'kind parsed'); - assert.strictEqual(data.name, 'Realm Sync', 'name parsed'); + assert.strictEqual(data.name, 'Realm Sync', 'top-level name parsed'); assert.strictEqual( - (data.commands as any[])[0].codeRef.name, + (data.boxel as any).kind, + 'skill', + 'boxel.kind parsed', + ); + assert.strictEqual( + (data.boxel as any).commands[0].codeRef.name, 'SyncCommand', - 'nested command codeRef parsed', + 'nested boxel.commands codeRef parsed', ); assert.true( body.startsWith('# Realm Sync'), @@ -115,21 +120,21 @@ module( 'flat searchable description written', ); assert.strictEqual( - attrs.frontmatter.name, + attrs.boxel.name, 'Realm Sync', - 'nested frontmatter value carried', + 'boxel.name sourced from shared top-level name', ); assert.strictEqual( - attrs.frontmatter.commands[0].requiresApproval, + attrs.boxel.commands[0].requiresApproval, true, - 'nested command survives extraction', + 'nested boxel.command survives extraction', ); let routed = (attrs as Record)[FILE_FIELD_META]; assert.deepEqual( - routed?.frontmatter?.adoptsFrom, + routed?.boxel?.adoptsFrom, identifyCard(SkillField), - 'routed per-field meta points frontmatter at SkillField', + 'routed per-field meta points boxel at SkillField', ); }); @@ -152,7 +157,7 @@ module( fieldsMeta, ); assert.deepEqual( - (resource.meta as any).fields?.frontmatter?.adoptsFrom, + (resource.meta as any).fields?.boxel?.adoptsFrom, identifyCard(SkillField), 'buildFileResource now threads meta.fields (gap closed)', ); @@ -163,21 +168,21 @@ module( undefined, ); assert.true( - instance.frontmatter instanceof SkillField, - 'frontmatter rehydrated as SkillField subclass', + instance.boxel instanceof SkillField, + 'boxel rehydrated as SkillField subclass', ); assert.strictEqual( - instance.frontmatter.name, + instance.boxel.name, 'Realm Sync', 'subclass field survives round-trip', ); assert.strictEqual( - instance.frontmatter.commands.length, + instance.boxel.commands.length, 1, 'commands survive round-trip', ); assert.strictEqual( - instance.frontmatter.commands[0].codeRef.name, + instance.boxel.commands[0].codeRef.name, 'SyncCommand', 'command codeRef survives round-trip', ); From 887918b84316bdd5ed6115063aa2d8cc0f8f8805 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 11:06:23 -0400 Subject: [PATCH 05/14] Rename SkillField -> SkillFrontmatterField Consistent with BoxelFrontmatterField; the type models the `boxel:` frontmatter namespace of a skill markdown file. Co-Authored-By: Claude Opus 4.8 --- packages/base/boxel-frontmatter-field.gts | 3 +- packages/base/boxel-frontmatter-kinds.ts | 4 +-- packages/base/command-field.gts | 3 +- packages/base/markdown-file-def.gts | 2 +- ...-field.gts => skill-frontmatter-field.gts} | 2 +- packages/base/skill.gts | 2 +- .../utils/file-def-attributes-extractor.ts | 2 +- .../markdown-skill-frontmatter-test.gts | 30 +++++++++---------- 8 files changed, 25 insertions(+), 23 deletions(-) rename packages/base/{skill-field.gts => skill-frontmatter-field.gts} (93%) diff --git a/packages/base/boxel-frontmatter-field.gts b/packages/base/boxel-frontmatter-field.gts index 5d457d998f2..c408291f847 100644 --- a/packages/base/boxel-frontmatter-field.gts +++ b/packages/base/boxel-frontmatter-field.gts @@ -12,7 +12,8 @@ import StringField from './string'; // under `boxel:` so generic top-level keys (notably `name`/`description`, which // are shared with Claude Code's SKILL.md) never trigger Boxel behavior. When the // namespace declares a recognized `kind` (e.g. `kind: skill`), the concrete -// instance is a subclass (e.g. `SkillField`) selected by the kind registry. +// instance is a subclass (e.g. `SkillFrontmatterField`) selected by the kind +// registry. export class BoxelFrontmatterField extends FieldDef { static displayName = 'Boxel Frontmatter'; diff --git a/packages/base/boxel-frontmatter-kinds.ts b/packages/base/boxel-frontmatter-kinds.ts index c3dc0f657a5..ce0959a787f 100644 --- a/packages/base/boxel-frontmatter-kinds.ts +++ b/packages/base/boxel-frontmatter-kinds.ts @@ -1,12 +1,12 @@ import { BoxelFrontmatterField } from './boxel-frontmatter-field'; -import { SkillField } from './skill-field'; +import { SkillFrontmatterField } from './skill-frontmatter-field'; // Maps a `boxel.kind` value to the FieldDef that models it. This registry lives // above `MarkdownDef` so the base markdown type stays ignorant of its kinds: // adding a new kind (e.g. `recipe`, `persona`) is a new field type + an entry // here, not a new FileDef subclass or extension rule. const BOXEL_FIELD_BY_KIND: Record = { - skill: SkillField, + skill: SkillFrontmatterField, }; export function boxelFieldForKind( diff --git a/packages/base/command-field.gts b/packages/base/command-field.gts index 77a4fa47557..7a08c1ee7f7 100644 --- a/packages/base/command-field.gts +++ b/packages/base/command-field.gts @@ -15,7 +15,8 @@ import { buildCommandFunctionName } from '@cardstack/runtime-common'; // A single command attached to a skill: an absolute code reference plus the // approval policy the host applies before invoking it. Shared by the legacy -// `Skill` card (`commands`) and by `SkillField` (skill markdown frontmatter). +// `Skill` card (`commands`) and by `SkillFrontmatterField` (skill markdown +// frontmatter). export class CommandField extends FieldDef { static displayName = 'CommandField'; static icon = CommandIcon; diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index d3b2de232ae..268c7476ba6 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -462,7 +462,7 @@ export class MarkdownDef extends FileDef { // The `boxel:` namespace of the file's YAML frontmatter. When it declares a // recognized `kind` (e.g. `boxel.kind: skill`), this holds the matching - // subclass instance (e.g. `SkillField`); otherwise it stays a base + // subclass instance (e.g. `SkillFrontmatterField`); otherwise it stays a base // `BoxelFrontmatterField`. The concrete subclass is recorded in // `meta.fields.boxel.adoptsFrom` by `extractAttributes`, so it rehydrates as // the right type on read. diff --git a/packages/base/skill-field.gts b/packages/base/skill-frontmatter-field.gts similarity index 93% rename from packages/base/skill-field.gts rename to packages/base/skill-frontmatter-field.gts index 6ac269d120e..17b40475f98 100644 --- a/packages/base/skill-field.gts +++ b/packages/base/skill-frontmatter-field.gts @@ -14,7 +14,7 @@ import { CommandField } from './command-field'; // upload flow reads `markdownDef.boxel.commands` exactly as it reads // `Skill.commands`. `name`/`description` are sourced from the shared top-level // frontmatter (see `MarkdownDef.extractAttributes`). -export class SkillField extends BoxelFrontmatterField { +export class SkillFrontmatterField extends BoxelFrontmatterField { static displayName = 'Skill'; @field name = contains(StringField); diff --git a/packages/base/skill.gts b/packages/base/skill.gts index d1afe2d482d..6c9133bfbf8 100644 --- a/packages/base/skill.gts +++ b/packages/base/skill.gts @@ -14,7 +14,7 @@ import { CommandField } from './command-field'; export const isSkillCard = Symbol.for('is-skill-card'); // Re-exported for back-compat: `CommandField` now lives in its own module so -// `SkillField` (skill markdown frontmatter) can reuse it without depending on +// `SkillFrontmatterField` (skill markdown frontmatter) can reuse it without depending on // the legacy `Skill` card. export { CommandField }; diff --git a/packages/host/app/utils/file-def-attributes-extractor.ts b/packages/host/app/utils/file-def-attributes-extractor.ts index 9ee78a21e47..d8c1dc8304e 100644 --- a/packages/host/app/utils/file-def-attributes-extractor.ts +++ b/packages/host/app/utils/file-def-attributes-extractor.ts @@ -436,7 +436,7 @@ export function buildFileResource( meta: { adoptsFrom, // Per-field subclass overrides for nested polymorphic fields (e.g. - // `boxel` → SkillField). Without this the field rehydrates as its + // `boxel` → SkillFrontmatterField). Without this the field rehydrates as its // declared base type. Supplied by `extractAttributes` (see below). ...(fieldsMeta ? { fields: fieldsMeta } : {}), ...(queryFieldDefs ? { queryFieldDefs } : {}), diff --git a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts index 231c2bbeb32..ae9e208f781 100644 --- a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts +++ b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts @@ -1,12 +1,12 @@ // CS-11545 — a skill is markdown with `kind: skill` frontmatter, not a SkillDef -// subclass. These tests exercise the real MarkdownDef + SkillField: +// subclass. These tests exercise the real MarkdownDef + SkillFrontmatterField: // // 1. parseFrontmatter pulls the YAML block off the top of the body. // 2. MarkdownDef.extractAttributes parses frontmatter, writes a flat // searchable `kind`, carries the nested `frontmatter` value, and routes the -// per-field subclass marker (SkillField) via the file-field-meta symbol. +// per-field subclass marker (SkillFrontmatterField) via the file-field-meta symbol. // 3. The write→read round-trip (extractor lift → buildFileResource → -// createFromSerialized) rehydrates `frontmatter` as a SkillField with its +// createFromSerialized) rehydrates `frontmatter` as a SkillFrontmatterField with its // commands intact — closing the write-path gap the CS-11568 spike pinned. // 4. Plain markdown (no frontmatter) is unaffected. // @@ -68,8 +68,8 @@ module( let { MarkdownDef } = await loader.import( `${baseRealm.url}markdown-file-def`, ); - let { SkillField } = await loader.import( - `${baseRealm.url}skill-field`, + let { SkillFrontmatterField } = await loader.import( + `${baseRealm.url}skill-frontmatter-field`, ); let { BoxelFrontmatterField } = await loader.import( `${baseRealm.url}boxel-frontmatter-field`, @@ -77,7 +77,7 @@ module( let { parseFrontmatter } = await loader.import( `${baseRealm.url}frontmatter-parse`, ); - return { MarkdownDef, SkillField, BoxelFrontmatterField, parseFrontmatter }; + return { MarkdownDef, SkillFrontmatterField, BoxelFrontmatterField, parseFrontmatter }; } test('parseFrontmatter splits the YAML block from the body', async function (assert) { @@ -108,8 +108,8 @@ module( ); }); - test('extractAttributes surfaces flat kind, nested frontmatter, and routes the SkillField marker', async function (assert) { - let { MarkdownDef, SkillField } = await loadBase(); + test('extractAttributes surfaces flat kind, nested frontmatter, and routes the SkillFrontmatterField marker', async function (assert) { + let { MarkdownDef, SkillFrontmatterField } = await loadBase(); let url = `${testRealmURL}skills/realm-sync/SKILL.md`; let attrs = await MarkdownDef.extractAttributes(url, streamOf(SKILL_MD), {}); @@ -133,13 +133,13 @@ module( let routed = (attrs as Record)[FILE_FIELD_META]; assert.deepEqual( routed?.boxel?.adoptsFrom, - identifyCard(SkillField), - 'routed per-field meta points boxel at SkillField', + identifyCard(SkillFrontmatterField), + 'routed per-field meta points boxel at SkillFrontmatterField', ); }); - test('write -> read round-trip rehydrates frontmatter as SkillField with commands', async function (assert) { - let { MarkdownDef, SkillField } = await loadBase(); + test('write -> read round-trip rehydrates frontmatter as SkillFrontmatterField with commands', async function (assert) { + let { MarkdownDef, SkillFrontmatterField } = await loadBase(); let url = `${testRealmURL}skills/realm-sync/SKILL.md`; let attrs = await MarkdownDef.extractAttributes(url, streamOf(SKILL_MD), {}); @@ -158,7 +158,7 @@ module( ); assert.deepEqual( (resource.meta as any).fields?.boxel?.adoptsFrom, - identifyCard(SkillField), + identifyCard(SkillFrontmatterField), 'buildFileResource now threads meta.fields (gap closed)', ); @@ -168,8 +168,8 @@ module( undefined, ); assert.true( - instance.boxel instanceof SkillField, - 'boxel rehydrated as SkillField subclass', + instance.boxel instanceof SkillFrontmatterField, + 'boxel rehydrated as SkillFrontmatterField subclass', ); assert.strictEqual( instance.boxel.name, From 61465514756f9fabb25f1df0e31e8a872079523b Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 11:40:53 -0400 Subject: [PATCH 06/14] Promote JsonField to its own module JsonField was defined in commands/search-card-result.gts but is a generic primitive (arbitrary JSON object, not indexed) used across command.gts. Move it to packages/base/json-field.gts; re-export from the old location for back-compat. Co-Authored-By: Claude Opus 4.8 --- packages/base/boxel-frontmatter-field.gts | 29 ------------------- packages/base/boxel-frontmatter-kinds.ts | 25 ---------------- packages/base/commands/search-card-result.gts | 10 ++----- packages/base/json-field.gts | 17 +++++++++++ 4 files changed, 20 insertions(+), 61 deletions(-) delete mode 100644 packages/base/boxel-frontmatter-field.gts delete mode 100644 packages/base/boxel-frontmatter-kinds.ts create mode 100644 packages/base/json-field.gts diff --git a/packages/base/boxel-frontmatter-field.gts b/packages/base/boxel-frontmatter-field.gts deleted file mode 100644 index c408291f847..00000000000 --- a/packages/base/boxel-frontmatter-field.gts +++ /dev/null @@ -1,29 +0,0 @@ -import { - Component, - FieldDef, - field, - contains, - type BaseDefComponent, -} from './card-api'; -import StringField from './string'; - -// Base type for the value of `MarkdownDef.boxel` — the `boxel:` namespace of a -// markdown file's YAML frontmatter. Boxel-specific frontmatter is namespaced -// under `boxel:` so generic top-level keys (notably `name`/`description`, which -// are shared with Claude Code's SKILL.md) never trigger Boxel behavior. When the -// namespace declares a recognized `kind` (e.g. `kind: skill`), the concrete -// instance is a subclass (e.g. `SkillFrontmatterField`) selected by the kind -// registry. -export class BoxelFrontmatterField extends FieldDef { - static displayName = 'Boxel Frontmatter'; - - @field kind = contains(StringField); - - static embedded: BaseDefComponent = class Embedded extends Component< - typeof this - > { - - }; -} diff --git a/packages/base/boxel-frontmatter-kinds.ts b/packages/base/boxel-frontmatter-kinds.ts deleted file mode 100644 index ce0959a787f..00000000000 --- a/packages/base/boxel-frontmatter-kinds.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { BoxelFrontmatterField } from './boxel-frontmatter-field'; -import { SkillFrontmatterField } from './skill-frontmatter-field'; - -// Maps a `boxel.kind` value to the FieldDef that models it. This registry lives -// above `MarkdownDef` so the base markdown type stays ignorant of its kinds: -// adding a new kind (e.g. `recipe`, `persona`) is a new field type + an entry -// here, not a new FileDef subclass or extension rule. -const BOXEL_FIELD_BY_KIND: Record = { - skill: SkillFrontmatterField, -}; - -export function boxelFieldForKind( - kind: string | undefined, -): typeof BoxelFrontmatterField { - if (kind && Object.prototype.hasOwnProperty.call(BOXEL_FIELD_BY_KIND, kind)) { - return BOXEL_FIELD_BY_KIND[kind]; - } - return BoxelFrontmatterField; -} - -export function isKnownBoxelKind(kind: string | undefined): boolean { - return ( - !!kind && Object.prototype.hasOwnProperty.call(BOXEL_FIELD_BY_KIND, kind) - ); -} diff --git a/packages/base/commands/search-card-result.gts b/packages/base/commands/search-card-result.gts index b1bcb2b6357..f8c6fae02cc 100644 --- a/packages/base/commands/search-card-result.gts +++ b/packages/base/commands/search-card-result.gts @@ -18,7 +18,6 @@ import { contains, containsMany, field, - queryableValue, type CardContext, type Format, FieldDef, @@ -36,12 +35,9 @@ interface CardListSignature { context?: CardContext; } -export class JsonField extends FieldDef { - static [primitive]: Record; - static [queryableValue](_value: any, _stack: BaseDef[]): null { - return null; - } -} +// JsonField now lives in its own module so non-command code can reuse it. +// Re-exported here for back-compat with existing importers. +export { JsonField } from '../json-field'; export class QueryField extends FieldDef { static [primitive]: Query; diff --git a/packages/base/json-field.gts b/packages/base/json-field.gts new file mode 100644 index 00000000000..f5b4de73e4b --- /dev/null +++ b/packages/base/json-field.gts @@ -0,0 +1,17 @@ +import { primitive } from '@cardstack/runtime-common'; +import type { BaseDef } from './card-api'; +import { FieldDef, queryableValue } from './card-api'; + +// A field whose value is an arbitrary JSON object, round-tripped as-is. It is +// intentionally NOT indexed for search (queryableValue → null): it backs +// loosely-typed blobs (command payloads, raw frontmatter) that shouldn't bloat +// the search index, and the typed query engine can't filter arbitrary nested +// JSON paths anyway. Callers that need to filter project the searchable parts +// into their own typed fields. +export class JsonField extends FieldDef { + static displayName = 'JSON'; + static [primitive]: Record; + static [queryableValue](_value: any, _stack: BaseDef[]): null { + return null; + } +} From 12a04ecdddb46f246a8620eac8a01941b674f003 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 11:40:56 -0400 Subject: [PATCH 07/14] Capture full frontmatter in FrontmatterField.rawContent; add searchable kind - Rename BoxelFrontmatterField -> FrontmatterField; MarkdownDef.boxel -> MarkdownDef.frontmatter. The field now holds the entire frontmatter as JSON in `rawContent` (a JsonField), with SkillFrontmatterField adding typed name/description/commands on top for boxel.kind: skill. - Add a direct, indexed MarkdownDef.kind field (projected from boxel.kind) so skills are searchable via `eq: { kind: 'skill' }` on the file-meta query path. - Add a search test verifying the query finds skill markdown and excludes other-kind / plain markdown; update the round-trip test for the new shape. Co-Authored-By: Claude Opus 4.8 --- packages/base/frontmatter-field.gts | 36 ++ packages/base/frontmatter-kinds.ts | 29 ++ packages/base/markdown-file-def.gts | 81 ++-- packages/base/skill-frontmatter-field.gts | 15 +- .../utils/file-def-attributes-extractor.ts | 4 +- .../markdown-skill-frontmatter-test.gts | 353 +++++++++--------- .../markdown-skill-search-test.gts | 130 +++++++ 7 files changed, 433 insertions(+), 215 deletions(-) create mode 100644 packages/base/frontmatter-field.gts create mode 100644 packages/base/frontmatter-kinds.ts create mode 100644 packages/host/tests/integration/markdown-skill-search-test.gts diff --git a/packages/base/frontmatter-field.gts b/packages/base/frontmatter-field.gts new file mode 100644 index 00000000000..a697402fa71 --- /dev/null +++ b/packages/base/frontmatter-field.gts @@ -0,0 +1,36 @@ +import { + Component, + FieldDef, + field, + contains, + type BaseDefComponent, +} from './card-api'; +import { JsonField } from './json-field'; + +// The parsed YAML frontmatter of a markdown file, captured as JSON. The base +// type holds the entire frontmatter in `rawContent`; when the frontmatter +// declares a recognized `boxel.kind` (e.g. `skill`), the concrete instance is a +// subclass (e.g. `SkillFrontmatterField`) selected by the kind registry, which +// adds typed fields on top of the raw copy. +export class FrontmatterField extends FieldDef { + static displayName = 'Frontmatter'; + + // The entire frontmatter (all top-level keys), as JSON — a lossless raw copy. + // Not indexed for search; searchable bits are projected into typed fields + // (e.g. `MarkdownDef.kind`, `SkillFrontmatterField.name`). + @field rawContent = contains(JsonField); + + static embedded: BaseDefComponent = class Embedded extends Component< + typeof this + > { + get kind() { + let raw = this.args.model?.rawContent as + | { boxel?: { kind?: string } } + | undefined; + return raw?.boxel?.kind ?? ''; + } + + }; +} diff --git a/packages/base/frontmatter-kinds.ts b/packages/base/frontmatter-kinds.ts new file mode 100644 index 00000000000..266bbc7bff2 --- /dev/null +++ b/packages/base/frontmatter-kinds.ts @@ -0,0 +1,29 @@ +import { FrontmatterField } from './frontmatter-field'; +import { SkillFrontmatterField } from './skill-frontmatter-field'; + +// Maps a `boxel.kind` value to the FrontmatterField subclass that models it. +// This registry lives above `MarkdownDef` so the base markdown type stays +// ignorant of its kinds: adding a new kind (e.g. `recipe`, `persona`) is a new +// field type + an entry here, not a new FileDef subclass or extension rule. +const FRONTMATTER_FIELD_BY_KIND: Record = { + skill: SkillFrontmatterField, +}; + +export function frontmatterFieldForKind( + kind: string | undefined, +): typeof FrontmatterField { + if ( + kind && + Object.prototype.hasOwnProperty.call(FRONTMATTER_FIELD_BY_KIND, kind) + ) { + return FRONTMATTER_FIELD_BY_KIND[kind]; + } + return FrontmatterField; +} + +export function isKnownFrontmatterKind(kind: string | undefined): boolean { + return ( + !!kind && + Object.prototype.hasOwnProperty.call(FRONTMATTER_FIELD_BY_KIND, kind) + ); +} diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index 268c7476ba6..0aac59a2ae3 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -23,11 +23,11 @@ import { type ByteStream, type SerializedFile, } from './file-api'; -import { BoxelFrontmatterField } from './boxel-frontmatter-field'; +import { FrontmatterField } from './frontmatter-field'; import { - boxelFieldForKind, - isKnownBoxelKind, -} from './boxel-frontmatter-kinds'; + frontmatterFieldForKind, + isKnownFrontmatterKind, +} from './frontmatter-kinds'; import { parseFrontmatter } from './frontmatter-parse'; // Channel for routing per-field meta (e.g. the concrete subclass of a @@ -460,13 +460,17 @@ export class MarkdownDef extends FileDef { @field excerpt = contains(StringField); @field content = contains(StringField); - // The `boxel:` namespace of the file's YAML frontmatter. When it declares a - // recognized `kind` (e.g. `boxel.kind: skill`), this holds the matching - // subclass instance (e.g. `SkillFrontmatterField`); otherwise it stays a base - // `BoxelFrontmatterField`. The concrete subclass is recorded in - // `meta.fields.boxel.adoptsFrom` by `extractAttributes`, so it rehydrates as - // the right type on read. - @field boxel = contains(BoxelFrontmatterField); + // The frontmatter's `boxel.kind`, surfaced as a direct, indexed field so + // skills are findable via `searchFiles({ filter: { eq: { kind: 'skill' } } })`. + // Empty for plain markdown. + @field kind = contains(StringField); + + // The file's parsed YAML frontmatter. `rawContent` holds the whole thing as + // JSON; when `boxel.kind` names a recognized kind (e.g. `skill`) this + // rehydrates as the matching `FrontmatterField` subclass (e.g. + // `SkillFrontmatterField`) via `meta.fields.frontmatter.adoptsFrom`, set in + // `extractAttributes`. + @field frontmatter = contains(FrontmatterField); @field cardReferenceUrls = containsMany(StringField, { computeVia: function (this: MarkdownDef) { @@ -517,14 +521,12 @@ export class MarkdownDef extends FileDef { excerpt: string; content: string; cardReferenceUrls: string[]; - // Flat, searchable projection of the boxel namespace (e.g. `kind: 'skill'` - // → `searchFiles({ filter: { eq: { kind: 'skill' } } })`). The skill name - // is NOT surfaced flat because it collides with the FileDef's filename; it - // lives in `boxel.name`. + // The frontmatter's `boxel.kind`, as a direct searchable field (e.g. + // `searchFiles({ filter: { eq: { kind: 'skill' } } })`). kind?: string; - description?: string; - // The nested `boxel` field value, typed by `boxel.kind` via the registry. - boxel?: Record; + // The `frontmatter` field value: `{ rawContent, … }`, typed by + // `boxel.kind` via the registry. + frontmatter?: Record; }> > { let extension = getExtension(url); @@ -564,8 +566,7 @@ export class MarkdownDef extends FileDef { content: string; cardReferenceUrls: string[]; kind?: string; - description?: string; - boxel?: Record; + frontmatter?: Record; }> = { ...base, title: extractTitle(body, fallbackTitle), @@ -591,29 +592,31 @@ export class MarkdownDef extends FileDef { ? boxelNamespace.kind : undefined; if (kind !== undefined) { - attributes.kind = kind; // flat, searchable + attributes.kind = kind; // direct, indexed, searchable } - // When `boxel.kind` maps to a known subclass, assemble the `boxel` field - // value (kind + commands from the namespace; name/description from the - // shared top-level keys) and record the concrete subclass so it rehydrates - // as that type. The base BoxelFrontmatterField needs no override. - if (isKnownBoxelKind(kind)) { - attributes.boxel = { - kind, - name: frontmatterData.name, - description: frontmatterData.description, - commands: boxelNamespace?.commands, + // Capture the whole frontmatter as JSON whenever present (the raw, + // lossless copy). For a recognized `boxel.kind`, also populate the typed + // subclass fields — `name`/`description` from the shared top-level keys, + // `commands` from the boxel namespace — and record the concrete subclass so + // the field rehydrates as that type. The base FrontmatterField needs no + // override. + if (Object.keys(frontmatterData).length > 0) { + let frontmatterValue: Record = { + rawContent: frontmatterData, }; - if (typeof frontmatterData.description === 'string') { - attributes.description = frontmatterData.description; // flat, searchable - } - let adoptsFrom = identifyCard(boxelFieldForKind(kind)); - if (adoptsFrom) { - (attributes as Record)[fileFieldMetaSymbol] = { - boxel: { adoptsFrom }, - }; + if (isKnownFrontmatterKind(kind)) { + frontmatterValue.name = frontmatterData.name; + frontmatterValue.description = frontmatterData.description; + frontmatterValue.commands = boxelNamespace?.commands; + let adoptsFrom = identifyCard(frontmatterFieldForKind(kind)); + if (adoptsFrom) { + (attributes as Record)[fileFieldMetaSymbol] = { + frontmatter: { adoptsFrom }, + }; + } } + attributes.frontmatter = frontmatterValue; } return attributes; diff --git a/packages/base/skill-frontmatter-field.gts b/packages/base/skill-frontmatter-field.gts index 17b40475f98..ad1f93f1a50 100644 --- a/packages/base/skill-frontmatter-field.gts +++ b/packages/base/skill-frontmatter-field.gts @@ -6,15 +6,16 @@ import { type BaseDefComponent, } from './card-api'; import StringField from './string'; -import { BoxelFrontmatterField } from './boxel-frontmatter-field'; +import { FrontmatterField } from './frontmatter-field'; import { CommandField } from './command-field'; -// The `boxel:` namespace of a skill markdown file (`boxel.kind: skill`). Mirrors -// the field shape of the legacy `Skill` card so the host's command-definition -// upload flow reads `markdownDef.boxel.commands` exactly as it reads -// `Skill.commands`. `name`/`description` are sourced from the shared top-level -// frontmatter (see `MarkdownDef.extractAttributes`). -export class SkillFrontmatterField extends BoxelFrontmatterField { +// A skill markdown file's frontmatter (`boxel.kind: skill`). Adds typed fields +// on top of the base `FrontmatterField` (which holds the raw frontmatter in +// `rawContent`). Mirrors the field shape of the legacy `Skill` card so the +// host's command-definition upload flow reads `markdownDef.frontmatter.commands` +// exactly as it reads `Skill.commands`. `name`/`description` are sourced from the +// shared top-level frontmatter keys (see `MarkdownDef.extractAttributes`). +export class SkillFrontmatterField extends FrontmatterField { static displayName = 'Skill'; @field name = contains(StringField); diff --git a/packages/host/app/utils/file-def-attributes-extractor.ts b/packages/host/app/utils/file-def-attributes-extractor.ts index d8c1dc8304e..9aad73452f1 100644 --- a/packages/host/app/utils/file-def-attributes-extractor.ts +++ b/packages/host/app/utils/file-def-attributes-extractor.ts @@ -436,8 +436,8 @@ export function buildFileResource( meta: { adoptsFrom, // Per-field subclass overrides for nested polymorphic fields (e.g. - // `boxel` → SkillFrontmatterField). Without this the field rehydrates as its - // declared base type. Supplied by `extractAttributes` (see below). + // `frontmatter` → SkillFrontmatterField). Without this the field rehydrates + // as its declared base type. Supplied by `extractAttributes` (see below). ...(fieldsMeta ? { fields: fieldsMeta } : {}), ...(queryFieldDefs ? { queryFieldDefs } : {}), }, diff --git a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts index ae9e208f781..47ba2ddd7c8 100644 --- a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts +++ b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts @@ -1,18 +1,20 @@ -// CS-11545 — a skill is markdown with `kind: skill` frontmatter, not a SkillDef -// subclass. These tests exercise the real MarkdownDef + SkillFrontmatterField: +// CS-11545 — a skill is markdown with `boxel.kind: skill` frontmatter, not a +// SkillDef subclass. These tests exercise the real MarkdownDef + the +// FrontmatterField / SkillFrontmatterField field types: // // 1. parseFrontmatter pulls the YAML block off the top of the body. -// 2. MarkdownDef.extractAttributes parses frontmatter, writes a flat -// searchable `kind`, carries the nested `frontmatter` value, and routes the -// per-field subclass marker (SkillFrontmatterField) via the file-field-meta symbol. +// 2. MarkdownDef.extractAttributes parses frontmatter, writes a direct +// searchable `kind`, captures the whole frontmatter in `frontmatter.rawContent`, +// and routes the per-field subclass marker (SkillFrontmatterField) via the +// file-field-meta symbol. // 3. The write→read round-trip (extractor lift → buildFileResource → -// createFromSerialized) rehydrates `frontmatter` as a SkillFrontmatterField with its -// commands intact — closing the write-path gap the CS-11568 spike pinned. +// createFromSerialized) rehydrates `frontmatter` as a SkillFrontmatterField +// with its commands intact — closing the write-path gap the CS-11568 spike +// pinned. // 4. Plain markdown (no frontmatter) is unaffected. // // NOTE: requires the host test-services stack / CI to run (local host-test boot -// is documented-broken). Authored against the harness conventions in -// spike-cs-11568-filedef-poly-test.gts. +// is documented-broken). import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; @@ -52,161 +54,178 @@ boxel: Body paragraph. `; -module( - 'Integration | CS-11545 | markdown skill frontmatter', - function (hooks) { - setupRenderingTest(hooks); - setupBaseRealm(hooks); - setupLocalIndexing(hooks); - setupMockMatrix(hooks); - - hooks.beforeEach(function () { - loader = getService('loader-service').loader; - }); - - async function loadBase() { - let { MarkdownDef } = await loader.import( - `${baseRealm.url}markdown-file-def`, - ); - let { SkillFrontmatterField } = await loader.import( - `${baseRealm.url}skill-frontmatter-field`, - ); - let { BoxelFrontmatterField } = await loader.import( - `${baseRealm.url}boxel-frontmatter-field`, - ); - let { parseFrontmatter } = await loader.import( - `${baseRealm.url}frontmatter-parse`, - ); - return { MarkdownDef, SkillFrontmatterField, BoxelFrontmatterField, parseFrontmatter }; - } - - test('parseFrontmatter splits the YAML block from the body', async function (assert) { - let { parseFrontmatter } = await loadBase(); - let { data, body } = parseFrontmatter(SKILL_MD); - assert.strictEqual(data.name, 'Realm Sync', 'top-level name parsed'); - assert.strictEqual( - (data.boxel as any).kind, - 'skill', - 'boxel.kind parsed', - ); - assert.strictEqual( - (data.boxel as any).commands[0].codeRef.name, - 'SyncCommand', - 'nested boxel.commands codeRef parsed', - ); - assert.true( - body.startsWith('# Realm Sync'), - 'body excludes the frontmatter block', - ); - - let plain = parseFrontmatter('# Just markdown\n\nNo frontmatter.'); - assert.deepEqual(plain.data, {}, 'no frontmatter -> empty data'); - assert.strictEqual( - plain.body, - '# Just markdown\n\nNo frontmatter.', - 'no frontmatter -> body verbatim', - ); - }); - - test('extractAttributes surfaces flat kind, nested frontmatter, and routes the SkillFrontmatterField marker', async function (assert) { - let { MarkdownDef, SkillFrontmatterField } = await loadBase(); - let url = `${testRealmURL}skills/realm-sync/SKILL.md`; - let attrs = await MarkdownDef.extractAttributes(url, streamOf(SKILL_MD), {}); - - assert.strictEqual(attrs.kind, 'skill', 'flat searchable kind written'); - assert.strictEqual( - attrs.description, - 'Sync workspace files', - 'flat searchable description written', - ); - assert.strictEqual( - attrs.boxel.name, - 'Realm Sync', - 'boxel.name sourced from shared top-level name', - ); - assert.strictEqual( - attrs.boxel.commands[0].requiresApproval, - true, - 'nested boxel.command survives extraction', - ); - - let routed = (attrs as Record)[FILE_FIELD_META]; - assert.deepEqual( - routed?.boxel?.adoptsFrom, - identifyCard(SkillFrontmatterField), - 'routed per-field meta points boxel at SkillFrontmatterField', - ); - }); - - test('write -> read round-trip rehydrates frontmatter as SkillFrontmatterField with commands', async function (assert) { - let { MarkdownDef, SkillFrontmatterField } = await loadBase(); - let url = `${testRealmURL}skills/realm-sync/SKILL.md`; - let attrs = await MarkdownDef.extractAttributes(url, streamOf(SKILL_MD), {}); - - // Mirror what the file extractor does: lift the routed field meta out of - // the flat searchDoc and thread it into the resource's meta.fields. - let cleaned: Record = { ...attrs }; - let fieldsMeta = cleaned[FILE_FIELD_META]; - delete cleaned[FILE_FIELD_META]; - - let resource = buildFileResource( - url, - cleaned, - identifyCard(MarkdownDef)!, - undefined, - fieldsMeta, - ); - assert.deepEqual( - (resource.meta as any).fields?.boxel?.adoptsFrom, - identifyCard(SkillFrontmatterField), - 'buildFileResource now threads meta.fields (gap closed)', - ); - - let instance: any = await createFromSerialized( - resource as any, - { data: resource } as any, - undefined, - ); - assert.true( - instance.boxel instanceof SkillFrontmatterField, - 'boxel rehydrated as SkillFrontmatterField subclass', - ); - assert.strictEqual( - instance.boxel.name, - 'Realm Sync', - 'subclass field survives round-trip', - ); - assert.strictEqual( - instance.boxel.commands.length, - 1, - 'commands survive round-trip', - ); - assert.strictEqual( - instance.boxel.commands[0].codeRef.name, - 'SyncCommand', - 'command codeRef survives round-trip', - ); - }); - - test('plain markdown (no frontmatter) carries no kind and no routed meta', async function (assert) { - let { MarkdownDef } = await loadBase(); - let url = `${testRealmURL}notes/readme.md`; - let attrs = await MarkdownDef.extractAttributes( - url, - streamOf('# Readme\n\nHello.'), - {}, - ); - assert.strictEqual(attrs.kind, undefined, 'no kind for plain markdown'); - assert.strictEqual( - (attrs as Record)[FILE_FIELD_META], - undefined, - 'no routed field meta for plain markdown', - ); - assert.strictEqual(attrs.title, 'Readme', 'title still extracted'); - assert.true( - attrs.content.includes('Hello.'), - 'content still extracted', - ); - }); - }, -); +module('Integration | CS-11545 | markdown skill frontmatter', function (hooks) { + setupRenderingTest(hooks); + setupBaseRealm(hooks); + setupLocalIndexing(hooks); + setupMockMatrix(hooks); + + hooks.beforeEach(function () { + loader = getService('loader-service').loader; + }); + + async function loadBase() { + let { MarkdownDef } = await loader.import( + `${baseRealm.url}markdown-file-def`, + ); + let { SkillFrontmatterField } = await loader.import( + `${baseRealm.url}skill-frontmatter-field`, + ); + let { FrontmatterField } = await loader.import( + `${baseRealm.url}frontmatter-field`, + ); + let { parseFrontmatter } = await loader.import( + `${baseRealm.url}frontmatter-parse`, + ); + return { + MarkdownDef, + SkillFrontmatterField, + FrontmatterField, + parseFrontmatter, + }; + } + + test('parseFrontmatter splits the YAML block from the body', async function (assert) { + let { parseFrontmatter } = await loadBase(); + let { data, body } = parseFrontmatter(SKILL_MD); + assert.strictEqual(data.name, 'Realm Sync', 'top-level name parsed'); + assert.strictEqual((data.boxel as any).kind, 'skill', 'boxel.kind parsed'); + assert.strictEqual( + (data.boxel as any).commands[0].codeRef.name, + 'SyncCommand', + 'nested boxel.commands codeRef parsed', + ); + assert.true( + body.startsWith('# Realm Sync'), + 'body excludes the frontmatter block', + ); + + let plain = parseFrontmatter('# Just markdown\n\nNo frontmatter.'); + assert.deepEqual(plain.data, {}, 'no frontmatter -> empty data'); + assert.strictEqual( + plain.body, + '# Just markdown\n\nNo frontmatter.', + 'no frontmatter -> body verbatim', + ); + }); + + test('extractAttributes surfaces searchable kind, raw frontmatter, and routes the SkillFrontmatterField marker', async function (assert) { + let { MarkdownDef, SkillFrontmatterField } = await loadBase(); + let url = `${testRealmURL}skills/realm-sync/SKILL.md`; + let attrs = await MarkdownDef.extractAttributes( + url, + streamOf(SKILL_MD), + {}, + ); + + assert.strictEqual(attrs.kind, 'skill', 'direct searchable kind written'); + assert.strictEqual( + attrs.frontmatter.rawContent.boxel.kind, + 'skill', + 'rawContent holds the whole frontmatter (incl. boxel namespace)', + ); + assert.strictEqual( + attrs.frontmatter.rawContent.name, + 'Realm Sync', + 'rawContent includes shared top-level keys', + ); + assert.strictEqual( + attrs.frontmatter.name, + 'Realm Sync', + 'typed name sourced from shared top-level name', + ); + assert.true( + attrs.frontmatter.commands[0].requiresApproval, + 'typed commands sourced from the boxel namespace', + ); + + let routed = (attrs as Record)[FILE_FIELD_META]; + assert.deepEqual( + routed?.frontmatter?.adoptsFrom, + identifyCard(SkillFrontmatterField), + 'routed per-field meta points frontmatter at SkillFrontmatterField', + ); + }); + + test('write -> read round-trip rehydrates frontmatter as SkillFrontmatterField with commands', async function (assert) { + let { MarkdownDef, SkillFrontmatterField } = await loadBase(); + let url = `${testRealmURL}skills/realm-sync/SKILL.md`; + let attrs = await MarkdownDef.extractAttributes( + url, + streamOf(SKILL_MD), + {}, + ); + + // Mirror what the file extractor does: lift the routed field meta out of + // the flat searchDoc and thread it into the resource's meta.fields. + let cleaned: Record = { ...attrs }; + let fieldsMeta = cleaned[FILE_FIELD_META]; + delete cleaned[FILE_FIELD_META]; + + let resource = buildFileResource( + url, + cleaned, + identifyCard(MarkdownDef)!, + undefined, + fieldsMeta, + ); + assert.deepEqual( + (resource.meta as any).fields?.frontmatter?.adoptsFrom, + identifyCard(SkillFrontmatterField), + 'buildFileResource now threads meta.fields (gap closed)', + ); + + let instance: any = await createFromSerialized( + resource as any, + { data: resource } as any, + undefined, + ); + assert.true( + instance.frontmatter instanceof SkillFrontmatterField, + 'frontmatter rehydrated as SkillFrontmatterField subclass', + ); + assert.strictEqual( + instance.frontmatter.name, + 'Realm Sync', + 'typed subclass field survives round-trip', + ); + assert.strictEqual( + instance.frontmatter.rawContent.boxel.kind, + 'skill', + 'rawContent survives round-trip', + ); + assert.strictEqual( + instance.frontmatter.commands.length, + 1, + 'commands survive round-trip', + ); + assert.strictEqual( + instance.frontmatter.commands[0].codeRef.name, + 'SyncCommand', + 'command codeRef survives round-trip', + ); + }); + + test('plain markdown (no frontmatter) carries no kind and no frontmatter value', async function (assert) { + let { MarkdownDef } = await loadBase(); + let url = `${testRealmURL}notes/readme.md`; + let attrs = await MarkdownDef.extractAttributes( + url, + streamOf('# Readme\n\nHello.'), + {}, + ); + assert.strictEqual(attrs.kind, undefined, 'no kind for plain markdown'); + assert.strictEqual( + attrs.frontmatter, + undefined, + 'no frontmatter value for plain markdown', + ); + assert.strictEqual( + (attrs as Record)[FILE_FIELD_META], + undefined, + 'no routed field meta for plain markdown', + ); + assert.strictEqual(attrs.title, 'Readme', 'title still extracted'); + assert.true(attrs.content.includes('Hello.'), 'content still extracted'); + }); +}); diff --git a/packages/host/tests/integration/markdown-skill-search-test.gts b/packages/host/tests/integration/markdown-skill-search-test.gts new file mode 100644 index 00000000000..6a63441568e --- /dev/null +++ b/packages/host/tests/integration/markdown-skill-search-test.gts @@ -0,0 +1,130 @@ +// CS-11545 — verify a search query finds MarkdownDef files whose frontmatter +// declares `boxel.kind: skill`. The discriminator is projected onto the +// indexed `MarkdownDef.kind` field at extraction, so it's filterable via the +// same file-meta query path used for any other FileDef field (cf. the +// `eq: { url }` file-meta filter in realm-querying-test). +// +// Requires the host test-services stack / CI to run. + +import type { RenderingTestContext } from '@ember/test-helpers'; + +import { getService } from '@universal-ember/test-support'; +import { module, test } from 'qunit'; + +import { baseRealm, baseRRI } from '@cardstack/runtime-common'; +import type { Loader } from '@cardstack/runtime-common/loader'; +import type { RealmIndexQueryEngine } from '@cardstack/runtime-common/realm-index-query-engine'; + +import { + testRealmURL, + setupLocalIndexing, + setupIntegrationTestRealm, + setupRealmCacheTeardown, + withCachedRealmSetup, +} from '../helpers'; +import { setupMockMatrix } from '../helpers/mock-matrix'; +import { setupRenderingTest } from '../helpers/setup'; + +const SKILL_MD = `--- +name: Realm Sync +description: Sync workspace files +boxel: + kind: skill + commands: + - codeRef: + module: '@cardstack/boxel-host/commands/realm-sync' + name: SyncCommand + requiresApproval: true +--- +# Realm Sync +`; + +// A markdown file with frontmatter but a non-skill kind — must NOT match. +const RECIPE_MD = `--- +name: Pasta +boxel: + kind: recipe +--- +# Pasta +`; + +// Plain markdown, no frontmatter — must NOT match. +const PLAIN_MD = `# Just a note + +Nothing special here. +`; + +const markdownRef = { + module: baseRRI('markdown-file-def'), + name: 'MarkdownDef', +}; + +let loader: Loader; + +module('Integration | CS-11545 | markdown skill search', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function (this: RenderingTestContext) { + loader = getService('loader-service').loader; + }); + + setupLocalIndexing(hooks); + let mockMatrixUtils = setupMockMatrix(hooks); + setupRealmCacheTeardown(hooks); + + let queryEngine: RealmIndexQueryEngine; + + hooks.beforeEach(async function () { + let { realm } = await withCachedRealmSetup(async () => + setupIntegrationTestRealm({ + mockMatrixUtils, + contents: { + 'skills/realm-sync/SKILL.md': SKILL_MD, + 'recipes/pasta/SKILL.md': RECIPE_MD, + 'notes/plain.md': PLAIN_MD, + }, + }), + ); + queryEngine = realm.realmIndexQueryEngine; + }); + + test('finds MarkdownDef files where boxel.kind is "skill"', async function (assert) { + let result = (await queryEngine.searchCards({ + filter: { + on: markdownRef, + eq: { kind: 'skill' }, + }, + })) as unknown as { data: { id?: string; type: string }[] }; + + assert.deepEqual( + result.data.map((entry) => entry.id), + [`${testRealmURL}skills/realm-sync/SKILL.md`], + 'only the skill markdown file matches kind: skill', + ); + assert.strictEqual( + result.data[0]?.type, + 'file-meta', + 'the match is a file-meta resource (an indexed MarkdownDef file)', + ); + }); + + test('all three markdown files index as MarkdownDef (kind only discriminates the filter)', async function (assert) { + let result = (await queryEngine.searchCards({ + filter: { type: markdownRef }, + })) as unknown as { data: { id?: string }[] }; + + let ids = result.data.map((entry) => entry.id); + assert.ok( + ids.includes(`${testRealmURL}skills/realm-sync/SKILL.md`), + 'skill file is a MarkdownDef', + ); + assert.ok( + ids.includes(`${testRealmURL}recipes/pasta/SKILL.md`), + 'recipe file is also a MarkdownDef (different kind, not a different type)', + ); + assert.ok( + ids.includes(`${testRealmURL}notes/plain.md`), + 'plain markdown is a MarkdownDef too', + ); + }); +}); From abcca61ffac2abb1d0dfacab4623839c1fb1b106 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 11:45:43 -0400 Subject: [PATCH 08/14] Drop unused imports/vars in markdown skill search test Co-Authored-By: Claude Opus 4.8 --- .../integration/markdown-skill-search-test.gts | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/packages/host/tests/integration/markdown-skill-search-test.gts b/packages/host/tests/integration/markdown-skill-search-test.gts index 6a63441568e..8f0170d5840 100644 --- a/packages/host/tests/integration/markdown-skill-search-test.gts +++ b/packages/host/tests/integration/markdown-skill-search-test.gts @@ -6,13 +6,9 @@ // // Requires the host test-services stack / CI to run. -import type { RenderingTestContext } from '@ember/test-helpers'; - -import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; -import { baseRealm, baseRRI } from '@cardstack/runtime-common'; -import type { Loader } from '@cardstack/runtime-common/loader'; +import { baseRRI } from '@cardstack/runtime-common'; import type { RealmIndexQueryEngine } from '@cardstack/runtime-common/realm-index-query-engine'; import { @@ -59,15 +55,8 @@ const markdownRef = { name: 'MarkdownDef', }; -let loader: Loader; - module('Integration | CS-11545 | markdown skill search', function (hooks) { setupRenderingTest(hooks); - - hooks.beforeEach(function (this: RenderingTestContext) { - loader = getService('loader-service').loader; - }); - setupLocalIndexing(hooks); let mockMatrixUtils = setupMockMatrix(hooks); setupRealmCacheTeardown(hooks); From 8dd6a0fafe2b1b8a356a97fc29606f5bb2f795f6 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 13:13:38 -0400 Subject: [PATCH 09/14] Shim yaml into the card-runtime virtual network MarkdownDef parses frontmatter with `yaml`, but the card-runtime loader resolves bare imports to https://packages/; without a shim it 404s and markdown-file-def fails to load, so .md files error during indexing. Register yaml as an async-shimmed module (like ethers/uuid) and add it to host deps. Co-Authored-By: Claude Opus 4.8 --- packages/host/app/lib/externals.ts | 4 ++++ packages/host/package.json | 3 ++- pnpm-lock.yaml | 21 +++------------------ 3 files changed, 9 insertions(+), 19 deletions(-) diff --git a/packages/host/app/lib/externals.ts b/packages/host/app/lib/externals.ts index 91ee002f5a7..dbbd2e274c8 100644 --- a/packages/host/app/lib/externals.ts +++ b/packages/host/app/lib/externals.ts @@ -193,6 +193,10 @@ export function shimExternals(virtualNetwork: VirtualNetwork) { id: 'uuid', resolve: () => import('uuid'), }); + virtualNetwork.shimAsyncModule({ + id: 'yaml', + resolve: () => import('yaml'), + }); virtualNetwork.shimAsyncModule({ id: '@cardstack/runtime-common/marked-sync', resolve: () => import('@cardstack/runtime-common/marked-sync.ts'), diff --git a/packages/host/package.json b/packages/host/package.json index c5cb10a80f7..847869afdc3 100644 --- a/packages/host/package.json +++ b/packages/host/package.json @@ -189,7 +189,8 @@ "uuid": "catalog:", "vite": "^8.0.8", "wait-for-localhost-cli": "catalog:", - "wtfnode": "^0.10.1" + "wtfnode": "^0.10.1", + "yaml": "catalog:" }, "dependencies": { "ember-modify-based-class-resource": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21903b1b969..aa2ffc4364c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2437,6 +2437,9 @@ importers: wtfnode: specifier: ^0.10.1 version: 0.10.1 + yaml: + specifier: 'catalog:' + version: 2.9.0 packages/local-types: devDependencies: @@ -19123,19 +19126,13 @@ snapshots: '@percy/cli-command': 1.31.13(typescript@5.9.3) '@percy/cli-exec': 1.31.13(typescript@5.9.3) transitivePeerDependencies: - - bufferutil - - supports-color - typescript - - utf-8-validate '@percy/cli-build@1.31.13(typescript@5.9.3)': dependencies: '@percy/cli-command': 1.31.13(typescript@5.9.3) transitivePeerDependencies: - - bufferutil - - supports-color - typescript - - utf-8-validate '@percy/cli-command@1.31.13(typescript@5.9.3)': dependencies: @@ -19152,10 +19149,7 @@ snapshots: dependencies: '@percy/cli-command': 1.31.13(typescript@5.9.3) transitivePeerDependencies: - - bufferutil - - supports-color - typescript - - utf-8-validate '@percy/cli-doctor@1.31.13(typescript@5.9.3)': dependencies: @@ -19181,20 +19175,14 @@ snapshots: cross-spawn: 7.0.6 which: 2.0.2 transitivePeerDependencies: - - bufferutil - - supports-color - typescript - - utf-8-validate '@percy/cli-snapshot@1.31.13(typescript@5.9.3)': dependencies: '@percy/cli-command': 1.31.13(typescript@5.9.3) yaml: 2.9.0 transitivePeerDependencies: - - bufferutil - - supports-color - typescript - - utf-8-validate '@percy/cli-upload@1.31.13(typescript@5.9.3)': dependencies: @@ -19202,10 +19190,7 @@ snapshots: fast-glob: 3.3.3 image-size: 1.2.1 transitivePeerDependencies: - - bufferutil - - supports-color - typescript - - utf-8-validate '@percy/cli@1.31.13(typescript@5.9.3)': dependencies: From e06ffadd9d4de51d15c53b67ef8645681d90a95c Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 13:16:19 -0400 Subject: [PATCH 10/14] Make skill-markdown comments and test names evergreen Drop tracker IDs, the diagnostic/spike narration, and temporal phrasing from the comments and test module names introduced for skill markdown; state the current contract instead. Co-Authored-By: Claude Opus 4.8 --- packages/base/commands/search-card-result.gts | 4 ++-- packages/base/markdown-file-def.gts | 4 ++-- packages/base/skill.gts | 6 +++--- .../markdown-skill-frontmatter-test.gts | 20 ++++++++----------- .../markdown-skill-search-test.gts | 14 ++++++------- 5 files changed, 22 insertions(+), 26 deletions(-) diff --git a/packages/base/commands/search-card-result.gts b/packages/base/commands/search-card-result.gts index f8c6fae02cc..c1192732166 100644 --- a/packages/base/commands/search-card-result.gts +++ b/packages/base/commands/search-card-result.gts @@ -35,8 +35,8 @@ interface CardListSignature { context?: CardContext; } -// JsonField now lives in its own module so non-command code can reuse it. -// Re-exported here for back-compat with existing importers. +// `JsonField` lives in its own module so non-command code can reuse it. +// Re-exported here so it is importable from this module too. export { JsonField } from '../json-field'; export class QueryField extends FieldDef { diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index 0aac59a2ae3..40e4cd6fe3a 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -555,8 +555,8 @@ export class MarkdownDef extends FileDef { body = parsed.body; } catch (err) { // Invalid YAML: index the markdown without frontmatter rather than fail - // the whole file. TODO(CS-11545): surface this via indexing diagnostics - // instead of only a console warning (spec Risks — visible parse errors). + // the whole file. TODO: surface this via indexing diagnostics rather than + // only a console warning, so frontmatter parse errors stay visible. console.warn(`[markdown-file-def] frontmatter parse failed for ${url}:`, err); } diff --git a/packages/base/skill.gts b/packages/base/skill.gts index 6c9133bfbf8..185699bb4c2 100644 --- a/packages/base/skill.gts +++ b/packages/base/skill.gts @@ -13,9 +13,9 @@ import { CommandField } from './command-field'; export const isSkillCard = Symbol.for('is-skill-card'); -// Re-exported for back-compat: `CommandField` now lives in its own module so -// `SkillFrontmatterField` (skill markdown frontmatter) can reuse it without depending on -// the legacy `Skill` card. +// `CommandField` lives in its own module so `SkillFrontmatterField` (skill +// markdown frontmatter) can reuse it without importing the `Skill` card. +// Re-exported here so it is importable from this module too. export { CommandField }; export class Skill extends CardDef { diff --git a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts index 47ba2ddd7c8..a20601a5e8d 100644 --- a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts +++ b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts @@ -1,20 +1,16 @@ -// CS-11545 — a skill is markdown with `boxel.kind: skill` frontmatter, not a -// SkillDef subclass. These tests exercise the real MarkdownDef + the -// FrontmatterField / SkillFrontmatterField field types: +// A skill is markdown with `boxel.kind: skill` frontmatter, modeled by the +// FrontmatterField / SkillFrontmatterField field types on MarkdownDef. These +// tests exercise the real MarkdownDef: // // 1. parseFrontmatter pulls the YAML block off the top of the body. // 2. MarkdownDef.extractAttributes parses frontmatter, writes a direct -// searchable `kind`, captures the whole frontmatter in `frontmatter.rawContent`, -// and routes the per-field subclass marker (SkillFrontmatterField) via the -// file-field-meta symbol. +// searchable `kind`, captures the whole frontmatter in +// `frontmatter.rawContent`, and routes the per-field subclass marker +// (SkillFrontmatterField) via the file-field-meta symbol. // 3. The write→read round-trip (extractor lift → buildFileResource → // createFromSerialized) rehydrates `frontmatter` as a SkillFrontmatterField -// with its commands intact — closing the write-path gap the CS-11568 spike -// pinned. +// with its commands intact. // 4. Plain markdown (no frontmatter) is unaffected. -// -// NOTE: requires the host test-services stack / CI to run (local host-test boot -// is documented-broken). import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; @@ -54,7 +50,7 @@ boxel: Body paragraph. `; -module('Integration | CS-11545 | markdown skill frontmatter', function (hooks) { +module('Integration | markdown skill frontmatter', function (hooks) { setupRenderingTest(hooks); setupBaseRealm(hooks); setupLocalIndexing(hooks); diff --git a/packages/host/tests/integration/markdown-skill-search-test.gts b/packages/host/tests/integration/markdown-skill-search-test.gts index 8f0170d5840..33575af17b8 100644 --- a/packages/host/tests/integration/markdown-skill-search-test.gts +++ b/packages/host/tests/integration/markdown-skill-search-test.gts @@ -1,10 +1,10 @@ -// CS-11545 — verify a search query finds MarkdownDef files whose frontmatter -// declares `boxel.kind: skill`. The discriminator is projected onto the -// indexed `MarkdownDef.kind` field at extraction, so it's filterable via the -// same file-meta query path used for any other FileDef field (cf. the -// `eq: { url }` file-meta filter in realm-querying-test). +// A search query finds MarkdownDef files whose frontmatter declares +// `boxel.kind: skill`. The discriminator is projected onto the indexed +// `MarkdownDef.kind` field at extraction, so it's filterable via the same +// file-meta query path used for any other FileDef field (cf. the `eq: { url }` +// file-meta filter in realm-querying-test). // -// Requires the host test-services stack / CI to run. +// Runs under the host test-services stack / CI. import { module, test } from 'qunit'; @@ -55,7 +55,7 @@ const markdownRef = { name: 'MarkdownDef', }; -module('Integration | CS-11545 | markdown skill search', function (hooks) { +module('Integration | markdown skill search', function (hooks) { setupRenderingTest(hooks); setupLocalIndexing(hooks); let mockMatrixUtils = setupMockMatrix(hooks); From e3c06f5454f698afd15d8d5d6b06b120008dc3ff Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 14:30:26 -0400 Subject: [PATCH 11/14] Fix symbol-index type error in file-field-meta lift Index the file-field-meta symbol through a Record view; a Record rejects symbol keys (TS2538). Co-Authored-By: Claude Opus 4.8 --- packages/host/app/utils/file-def-attributes-extractor.ts | 5 +++-- .../components/markdown-skill-frontmatter-test.gts | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/host/app/utils/file-def-attributes-extractor.ts b/packages/host/app/utils/file-def-attributes-extractor.ts index 9aad73452f1..ef2daf75712 100644 --- a/packages/host/app/utils/file-def-attributes-extractor.ts +++ b/packages/host/app/utils/file-def-attributes-extractor.ts @@ -206,11 +206,12 @@ export class FileDefAttributesExtractor { // `search_doc`. The base FileDef sets the same `Symbol.for` key. let fieldMetaSymbol = Symbol.for('boxel:file-field-meta'); let cleanedDoc: Record = { ...searchDoc }; - let fieldsMeta = cleanedDoc[fieldMetaSymbol] as + let cleanedBag = cleanedDoc as Record; + let fieldsMeta = cleanedBag[fieldMetaSymbol] as | NonNullable | undefined; if (fieldsMeta) { - delete cleanedDoc[fieldMetaSymbol]; + delete cleanedBag[fieldMetaSymbol]; } return { status: 'ready', diff --git a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts index a20601a5e8d..043ef11d651 100644 --- a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts +++ b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts @@ -155,8 +155,9 @@ module('Integration | markdown skill frontmatter', function (hooks) { // Mirror what the file extractor does: lift the routed field meta out of // the flat searchDoc and thread it into the resource's meta.fields. let cleaned: Record = { ...attrs }; - let fieldsMeta = cleaned[FILE_FIELD_META]; - delete cleaned[FILE_FIELD_META]; + let cleanedBag = cleaned as Record; + let fieldsMeta = cleanedBag[FILE_FIELD_META]; + delete cleanedBag[FILE_FIELD_META]; let resource = buildFileResource( url, From 608f59d1749ca0c9a0967c6928d3994a642367cc Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 14:46:03 -0400 Subject: [PATCH 12/14] Move frontmatter field mapping onto the field subclasses MarkdownDef.extractAttributes was assembling skill-specific fields (name/description/commands), coupling the base markdown type to the skill schema. Add FrontmatterField.fromFrontmatter(frontmatter); the base keeps the raw copy and SkillFrontmatterField maps its own fields. MarkdownDef now only reads the generic boxel.kind discriminator and delegates. Co-Authored-By: Claude Opus 4.8 --- packages/base/frontmatter-field.gts | 10 ++++++++++ packages/base/markdown-file-def.gts | 22 ++++++++-------------- packages/base/skill-frontmatter-field.gts | 20 ++++++++++++++++++++ 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/packages/base/frontmatter-field.gts b/packages/base/frontmatter-field.gts index a697402fa71..a423cfabf6e 100644 --- a/packages/base/frontmatter-field.gts +++ b/packages/base/frontmatter-field.gts @@ -20,6 +20,16 @@ export class FrontmatterField extends FieldDef { // (e.g. `MarkdownDef.kind`, `SkillFrontmatterField.name`). @field rawContent = contains(JsonField); + // Map a file's parsed frontmatter into this field's serialized attributes. + // The base keeps the whole frontmatter as the raw copy; subclasses add their + // own typed fields. A subclass is the only thing that knows its own + // frontmatter schema. + static fromFrontmatter( + frontmatter: Record, + ): Record { + return { rawContent: frontmatter }; + } + static embedded: BaseDefComponent = class Embedded extends Component< typeof this > { diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index 40e4cd6fe3a..856f0223113 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -595,28 +595,22 @@ export class MarkdownDef extends FileDef { attributes.kind = kind; // direct, indexed, searchable } - // Capture the whole frontmatter as JSON whenever present (the raw, - // lossless copy). For a recognized `boxel.kind`, also populate the typed - // subclass fields — `name`/`description` from the shared top-level keys, - // `commands` from the boxel namespace — and record the concrete subclass so - // the field rehydrates as that type. The base FrontmatterField needs no - // override. + // `boxel.kind` selects the FrontmatterField subclass; the subclass maps the + // parsed frontmatter into its own field value (the base keeps the raw copy). + // MarkdownDef stays ignorant of any kind's schema. A recognized kind is + // recorded so the field rehydrates as that subclass on read. if (Object.keys(frontmatterData).length > 0) { - let frontmatterValue: Record = { - rawContent: frontmatterData, - }; + let frontmatterFieldClass = frontmatterFieldForKind(kind); + attributes.frontmatter = + frontmatterFieldClass.fromFrontmatter(frontmatterData); if (isKnownFrontmatterKind(kind)) { - frontmatterValue.name = frontmatterData.name; - frontmatterValue.description = frontmatterData.description; - frontmatterValue.commands = boxelNamespace?.commands; - let adoptsFrom = identifyCard(frontmatterFieldForKind(kind)); + let adoptsFrom = identifyCard(frontmatterFieldClass); if (adoptsFrom) { (attributes as Record)[fileFieldMetaSymbol] = { frontmatter: { adoptsFrom }, }; } } - attributes.frontmatter = frontmatterValue; } return attributes; diff --git a/packages/base/skill-frontmatter-field.gts b/packages/base/skill-frontmatter-field.gts index ad1f93f1a50..4ac7bb6c28d 100644 --- a/packages/base/skill-frontmatter-field.gts +++ b/packages/base/skill-frontmatter-field.gts @@ -22,6 +22,26 @@ export class SkillFrontmatterField extends FrontmatterField { @field description = contains(StringField); @field commands = containsMany(CommandField); + // `name`/`description` come from the shared top-level frontmatter keys; + // `commands` from the `boxel:` namespace. Only this subclass knows that + // mapping. + static fromFrontmatter( + frontmatter: Record, + ): Record { + let boxel = + frontmatter.boxel && + typeof frontmatter.boxel === 'object' && + !Array.isArray(frontmatter.boxel) + ? (frontmatter.boxel as Record) + : undefined; + return { + ...super.fromFrontmatter(frontmatter), + name: frontmatter.name, + description: frontmatter.description, + commands: boxel?.commands, + }; + } + static embedded: BaseDefComponent = class Embedded extends Component< typeof this > { From 96baf7bb88c15b577745451f0eea1dcab2ee03c0 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 15:49:06 -0400 Subject: [PATCH 13/14] Strip frontmatter from MarkdownDef.content content (and cardReferenceUrls) now derive from the post-frontmatter body, so the markdown / isolated / embedded render paths no longer show the raw YAML frontmatter block. title/excerpt already used the body. The parsed frontmatter remains in frontmatter.rawContent and the verbatim file is still served from the realm, so nothing is lost. Plain markdown without frontmatter is unchanged. Co-Authored-By: Claude Opus 4.8 --- packages/base/markdown-file-def.gts | 8 ++++++-- .../components/markdown-skill-frontmatter-test.gts | 8 ++++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/base/markdown-file-def.gts b/packages/base/markdown-file-def.gts index 856f0223113..9cd2fef48d9 100644 --- a/packages/base/markdown-file-def.gts +++ b/packages/base/markdown-file-def.gts @@ -571,9 +571,13 @@ export class MarkdownDef extends FileDef { ...base, title: extractTitle(body, fallbackTitle), excerpt: extractExcerpt(body), - content: markdown, + // The body with any frontmatter block stripped — what the markdown / + // isolated / embedded paths render. The parsed frontmatter lives in + // `frontmatter.rawContent`, and the verbatim file is always served from + // the realm, so nothing is lost. + content: body, cardReferenceUrls: extractCardReferenceUrls( - markdown, + body, url, new VirtualNetwork(), ), diff --git a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts index 043ef11d651..072db3727cb 100644 --- a/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts +++ b/packages/host/tests/integration/components/markdown-skill-frontmatter-test.gts @@ -115,6 +115,14 @@ module('Integration | markdown skill frontmatter', function (hooks) { ); assert.strictEqual(attrs.kind, 'skill', 'direct searchable kind written'); + assert.true( + attrs.content.startsWith('# Realm Sync'), + 'content is the body — the frontmatter block is stripped', + ); + assert.notOk( + attrs.content.includes('boxel:'), + 'content excludes the raw frontmatter', + ); assert.strictEqual( attrs.frontmatter.rawContent.boxel.kind, 'skill', From 6ad9e074fde438c3d69fe11a056c12c5f2553227 Mon Sep 17 00:00:00 2001 From: Luke Melia Date: Tue, 16 Jun 2026 18:31:47 -0400 Subject: [PATCH 14/14] Preserve nested field subclass meta when serving file-meta from the index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fileMetaDocumentFromIndex` rebuilt the served document's `meta` from scratch, threading `queryFieldDefs` but dropping `meta.fields` — the per-field subclass overrides for nested polymorphic fields. As a result a markdown file with `boxel.kind: skill` frontmatter, read back through the realm, rehydrated its `frontmatter` as the base `FrontmatterField` rather than as `SkillFrontmatterField`. Thread `meta.fields` through alongside `queryFieldDefs` so the concrete subclass survives the read. Co-Authored-By: Claude Opus 4.8 --- .../markdown-skill-search-test.gts | 32 ++++++++++++++++++- packages/runtime-common/realm.ts | 6 ++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/host/tests/integration/markdown-skill-search-test.gts b/packages/host/tests/integration/markdown-skill-search-test.gts index 33575af17b8..ddac38a9bce 100644 --- a/packages/host/tests/integration/markdown-skill-search-test.gts +++ b/packages/host/tests/integration/markdown-skill-search-test.gts @@ -6,9 +6,10 @@ // // Runs under the host test-services stack / CI. +import { getService } from '@universal-ember/test-support'; import { module, test } from 'qunit'; -import { baseRRI } from '@cardstack/runtime-common'; +import { baseRRI, baseRealm } from '@cardstack/runtime-common'; import type { RealmIndexQueryEngine } from '@cardstack/runtime-common/realm-index-query-engine'; import { @@ -116,4 +117,33 @@ module('Integration | markdown skill search', function (hooks) { 'plain markdown is a MarkdownDef too', ); }); + + test('reading a skill markdown file rehydrates frontmatter as SkillFrontmatterField', async function (assert) { + // The realm serves a file's `meta.fields` (the per-field subclass override) + // alongside the indexed resource, so the polymorphic `frontmatter` field + // rehydrates as its concrete subclass on read rather than the declared base. + let loader = getService('loader-service').loader; + let { SkillFrontmatterField } = await loader.import( + `${baseRealm.url}skill-frontmatter-field`, + ); + let store = getService('store'); + let url = `${testRealmURL}skills/realm-sync/SKILL.md`; + let instance: any = await store.get(url, { type: 'file-meta' }); + + assert.strictEqual(instance?.kind, 'skill', 'kind read back as skill'); + assert.true( + instance?.frontmatter instanceof SkillFrontmatterField, + 'frontmatter rehydrated as SkillFrontmatterField, not the base FrontmatterField', + ); + assert.strictEqual( + instance.frontmatter.commands.length, + 1, + 'typed commands survive the realm file-meta read', + ); + assert.strictEqual( + instance.frontmatter.commands[0].codeRef.name, + 'SyncCommand', + 'command codeRef survives the realm file-meta read', + ); + }); }); diff --git a/packages/runtime-common/realm.ts b/packages/runtime-common/realm.ts index 6f9f321ec5f..781114275a9 100644 --- a/packages/runtime-common/realm.ts +++ b/packages/runtime-common/realm.ts @@ -4276,6 +4276,12 @@ export class Realm { adoptsFrom, realmInfo, realmURL: this.url as RealmIdentifier, + // Per-field subclass overrides for nested polymorphic fields (e.g. + // `frontmatter` → SkillFrontmatterField). Without this the field + // rehydrates as its declared base type when the document is read. + ...(fileEntry.resource?.meta?.fields + ? { fields: fileEntry.resource.meta.fields } + : {}), ...(fileEntry.resource?.meta?.queryFieldDefs ? { queryFieldDefs: fileEntry.resource.meta.queryFieldDefs } : {}),