Skip to content
Merged
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@
"undici": "^6.22.0",
"uuid": "^9.0.1",
"web-tree-sitter": "^0.24.7",
"ws": "^8.18.0"
"ws": "^8.18.0",
"yaml": "^2.8.1"
},
"optionalDependencies": {
"@lydell/node-pty": "1.2.0-beta.10",
Expand Down
116 changes: 116 additions & 0 deletions packages/core/src/skills/skill-load.real-parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import { parseSkillContent } from './skill-load.js';

describe('parseSkillContent with real YAML parser', () => {
const testPath = '/test/extension/skills/test-skill/SKILL.md';

it('parses folded block scalar descriptions (>)', () => {
const markdown = `---
name: test-skill
description: >
This is a folded
multiline description.
---

Body.
`;
const config = parseSkillContent(markdown, testPath);
expect(config.name).toBe('test-skill');
expect(config.description).toBe(
'This is a folded multiline description.\n',
);
});

it('parses literal block scalar descriptions (|)', () => {
const markdown = `---
name: test-skill
description: |
Line one.
Line two.
---
Body.
`;
const config = parseSkillContent(markdown, testPath);
expect(config.description).toBe('Line one.\nLine two.\n');
});

it('parses strip-chomped folded block scalar (>-)', () => {
const markdown = `---
name: test-skill
description: >-
No trailing newline.
---
Body.
`;
const config = parseSkillContent(markdown, testPath);
expect(config.description).toBe('No trailing newline.');
});

it('does not coerce date-like values to Date objects', () => {
const markdown = `---
name: test-skill
description: A skill created on 2024-01-01
---
Body.
`;
const config = parseSkillContent(markdown, testPath);
expect(typeof config.description).toBe('string');
});

it('handles allowedTools array correctly', () => {
const markdown = `---
name: test-skill
description: A test skill
allowedTools:
- read_file
- write_file
---
Body.
`;
const config = parseSkillContent(markdown, testPath);
expect(config.allowedTools).toEqual(['read_file', 'write_file']);
});

it('handles complex frontmatter with mixed field types', () => {
const markdown = `---
name: test-skill
description: >
Manage the full lifecycle of
cloud resources.
allowedTools:
- read_file
- write_file
model: qwen-max
argument-hint: "[resource-type]"
priority: 10
disable-model-invocation: true
---
Body content here.
`;
const config = parseSkillContent(markdown, testPath);
expect(config.name).toBe('test-skill');
expect(config.description).toContain('Manage the full lifecycle');
expect(config.allowedTools).toEqual(['read_file', 'write_file']);
expect(config.model).toBe('qwen-max');
expect(config.argumentHint).toBe('[resource-type]');
expect(config.priority).toBe(10);
expect(config.disableModelInvocation).toBe(true);
});

it('falls back gracefully for malformed YAML', () => {
const markdown = `---
name: test-skill
description: value with unmatched "quote
---
Body.
`;
const config = parseSkillContent(markdown, testPath);
expect(config.name).toBe('test-skill');
});
});
19 changes: 5 additions & 14 deletions packages/core/src/skills/skill-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import * as os from 'os';
import { watch as watchFs, type FSWatcher } from 'chokidar';
import { resolveBundleDir } from '../utils/bundlePaths.js';
import { parse as parseYaml } from '../utils/yaml-parser.js';
import * as yaml from 'yaml';
import type {
SkillConfig,
SkillLevel,
Expand Down Expand Up @@ -702,20 +701,12 @@ export class SkillManager {
}

// Extract hooks configuration
// Use full YAML parser for hooks as they have nested structures
let hooks: SkillHooksSettings | undefined;
if (frontmatterYaml.includes('hooks:')) {
// Re-parse with full YAML parser to get nested hooks structure
const fullFrontmatter = yaml.parse(frontmatterYaml) as Record<
string,
unknown
>;
const hooksRaw = fullFrontmatter['hooks'] as
| Record<string, unknown>
| undefined;
if (hooksRaw !== undefined) {
hooks = this.parseHooksConfig(hooksRaw);
}
const hooksRaw = frontmatter['hooks'] as
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
| Record<string, unknown>
| undefined;
if (hooksRaw !== undefined) {
hooks = this.parseHooksConfig(hooksRaw);
}

// Set skillRoot to the directory containing SKILL.md
Expand Down
2 changes: 0 additions & 2 deletions packages/core/src/subagents/subagent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
// Note: yaml package would need to be added as a dependency
// For now, we'll use a simple YAML parser implementation
import {
parse as parseYaml,
stringify as stringifyYaml,
Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/utils/yaml-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,76 @@ describe('yaml-parser', () => {
},
});
});

it('should parse YAML folded block scalar (>)', () => {
const input =
'name: test-skill\ndescription: >\n This is a folded\n multiline description.';
const result = parse(input);
expect(result['name']).toBe('test-skill');
expect(result['description']).toBe(
'This is a folded multiline description.\n',
);
});

it('should parse YAML literal block scalar (|)', () => {
const input =
'name: test-skill\ndescription: |\n Line one.\n Line two.';
const result = parse(input);
expect(result['name']).toBe('test-skill');
expect(result['description']).toBe('Line one.\nLine two.\n');
});

it('should parse YAML block scalar with strip chomping (>-)', () => {
const input =
'name: test-skill\ndescription: >-\n Folded without trailing newline.';
const result = parse(input);
expect(result['name']).toBe('test-skill');
expect(result['description']).toBe('Folded without trailing newline.');
});

it('should not coerce date-like strings into Date objects', () => {
const input = 'name: test\ncreated: 2024-01-01';
const result = parse(input);
expect(typeof result['created']).toBe('string');
expect(result['created']).toBe('2024-01-01');
});

it('should strip bare keys with no value', () => {
const input = 'name: test\nhooks:';
const result = parse(input);
expect(result['name']).toBe('test');
expect(result['hooks']).toBeUndefined();
});

it('should strip explicit null and tilde values', () => {
const input = 'a: null\nb: ~';
const result = parse(input);
expect(result['a']).toBeUndefined();
expect(result['b']).toBeUndefined();
});

it('should treat yes/no as strings in YAML 1.2 core schema', () => {
const input = 'answer: yes\nother: no';
const result = parse(input);
expect(result['answer']).toBe('yes');
expect(result['other']).toBe('no');
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
});

it('should fall back to simple parser on invalid YAML', () => {
const input = 'name: test\ndescription: value with unmatched "quote';
const result = parse(input);
expect(result['name']).toBe('test');
});

it('should handle empty input gracefully', () => {
const result = parse('');
expect(result).toEqual({});
});

it('should handle comment-only input gracefully', () => {
const result = parse('# just a comment');
expect(result).toEqual({});
});
});

describe('stringify', () => {
Expand Down
52 changes: 46 additions & 6 deletions packages/core/src/utils/yaml-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,60 @@
* SPDX-License-Identifier: Apache-2.0
*/

import * as yaml from 'yaml';
import { createDebugLogger } from './debugLogger.js';

const debugLogger = createDebugLogger('YAML_PARSER');

/**
* Simple YAML parser for subagent frontmatter.
* This is a minimal implementation that handles the basic YAML structures
* needed for subagent configuration files.
* Parses a YAML string with full spec support (block scalars, nested
* structures, etc.), falling back to the simple parser on failure so
* that slightly malformed frontmatter still loads where possible.
*
* @param yamlString - YAML string to parse
* @returns Parsed object
*/
export function parse(yamlString: string): Record<string, unknown> {
try {
const result = yaml.parse(yamlString, { schema: 'core' });
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
if (result && typeof result === 'object' && !Array.isArray(result)) {
return stripNullValues(result as Record<string, unknown>);
}
} catch (error) {
debugLogger.debug(
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
`Full YAML parser failed, falling back to simple parser: ${error}`,
);
}
return parseSimple(yamlString);
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
}

/**
* Strips top-level null values so callers can keep using `!== undefined`.
* yaml.parse returns null for bare keys (e.g. `hooks:`) and explicit
* nulls (`key: null`, `key: ~`), while the old simple parser omitted
* them entirely — normalizing here keeps the contract consistent.
*/
function stripNullValues(
obj: Record<string, unknown>,
): Record<string, unknown> {
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (value !== null) {
Comment thread
yiliang114 marked this conversation as resolved.
Resolved
cleaned[key] = value;
}
}
return cleaned;
}

/**
* Parses a simple YAML string into a JavaScript object.
* Supports basic key-value pairs, arrays, and nested objects.
* Simple YAML parser for subagent frontmatter.
* This is a minimal implementation that handles the basic YAML structures
* needed for subagent configuration files.
*
* @param yamlString - YAML string to parse
* @returns Parsed object
*/
export function parse(yamlString: string): Record<string, unknown> {
function parseSimple(yamlString: string): Record<string, unknown> {
const lines = yamlString
.split('\n')
.filter((line) => line.trim() && !line.trim().startsWith('#'));
Expand Down
Loading