Swadhin Biswas feat: implement mervelas cli engine, built-in tools, and reactive UI · 4/3/2026
0358986
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
/**
* Frontmatter parser for markdown files
* Extracts and parses YAML frontmatter between --- delimiters
*/
import { logForDebugging } from './debug.js'
import type { HooksSettings } from './settings/types.js'
import { parseYaml } from './yaml.js'
export type FrontmatterData = {
// YAML can return null for keys with no value (e.g., "key:" with nothing after)
'allowed-tools'?: string | string[] | null
description?: string | null
// Memory type: 'user', 'feedback', 'project', or 'reference'
// Only applicable to memory files; narrowed via parseMemoryType() in src/memdir/memoryTypes.ts
type?: string | null
'argument-hint'?: string | null
when_to_use?: string | null
version?: string | null
// Only applicable to slash commands -- a string similar to a boolean env var
// to determine whether to make them visible to the SlashCommand tool.
'hide-from-slash-command-tool'?: string | null
// Model alias or name (e.g., 'haiku', 'sonnet', 'opus', or specific model names)
// Use 'inherit' for commands to use the parent model
model?: string | null
// Comma-separated list of skill names to preload (only applicable to agents)
skills?: string | null
// Whether users can invoke this skill by typing /skill-name
// 'true' = user can type /skill-name to invoke
// 'false' = only model can invoke via Skill tool
// Default depends on source: commands/ defaults to true, skills/ defaults to false
'user-invocable'?: string | null
// Hooks to register when this skill is invoked
// Keys are hook events (PreToolUse, PostToolUse, Stop, etc.)
// Values are arrays of matcher configurations with hooks
// Validated by HooksSchema in loadSkillsDir.ts
hooks?: HooksSettings | null
// Effort level for agents (e.g., 'low', 'medium', 'high', 'max', or an integer)
// Controls the thinking effort used by the agent's model
effort?: string | null
// Execution context for skills: 'inline' (default) or 'fork' (run as sub-agent)
// 'inline' = skill content expands into the current conversation
// 'fork' = skill runs in a sub-agent with separate context and token budget
context?: 'inline' | 'fork' | null
// Agent type to use when forked (e.g., 'Bash', 'general-purpose')
// Only applicable when context is 'fork'
agent?: string | null
// Glob patterns for file paths this skill applies to. Accepts either a
// comma-separated string or a YAML list of strings.
// When set, the skill is only activated when the model touches matching files
// Uses the same format as MERVELAS.md paths frontmatter
paths?: string | string[] | null
// Shell to use for !`cmd` and ```! blocks in skill/command .md content.
// 'bash' (default) or 'powershell'. File-scoped — applies to all !-blocks.
// Never consults settings.defaultShell: skills are portable across platforms,
// so the author picks the shell, not the reader. See docs/design/ps-shell-selection.md §5.3.
shell?: string | null
[key: string]: unknown
}
export type ParsedMarkdown = {
frontmatter: FrontmatterData
content: string
}
// Characters that require quoting in YAML values (when unquoted)
// - { } are flow mapping indicators
// - * is anchor/alias indicator
// - [ ] are flow sequence indicators
// - ': ' (colon followed by space) is key indicator — causes 'Nested mappings
// are not allowed in compact mappings' when it appears mid-value. Match the
// pattern rather than bare ':' so '12:34' times and 'https://' URLs stay unquoted.
// - # is comment indicator
// - & is anchor indicator
// - ! is tag indicator
// - | > are block scalar indicators (only at start)
// - % is directive indicator (only at start)
// - @ ` are reserved
const YAML_SPECIAL_CHARS = /[{}[\]*&#!|>%@`]|: /
/**
* Pre-processes frontmatter text to quote values that contain special YAML characters.
* This allows glob patterns like **\/*.{ts,tsx} to be parsed correctly.
*/
function quoteProblematicValues(frontmatterText: string): string {
const lines = frontmatterText.split('\n')
const result: string[] = []
for (const line of lines) {
// Match simple key: value lines (not indented, not list items, not block scalars)
const match = line.match(/^([a-zA-Z_-]+):\s+(.+)$/)
if (match) {
const [, key, value] = match
if (!key || !value) {
result.push(line)
continue
}
// Skip if already quoted
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
result.push(line)
continue
}
// Quote if contains special YAML characters
if (YAML_SPECIAL_CHARS.test(value)) {
// Use double quotes and escape any existing double quotes
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
result.push(`${key}: "${escaped}"`)
continue
}
}
result.push(line)
}
return result.join('\n')
}
export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/
/**
* Parses markdown content to extract frontmatter and content
* @param markdown The raw markdown content
* @returns Object containing parsed frontmatter and content without frontmatter
*/
export function parseFrontmatter(
markdown: string,
sourcePath?: string,
): ParsedMarkdown {
const match = markdown.match(FRONTMATTER_REGEX)
if (!match) {
// No frontmatter found
return {
frontmatter: {},
content: markdown,
}
}
const frontmatterText = match[1] || ''
const content = markdown.slice(match[0].length)
let frontmatter: FrontmatterData = {}
try {
const parsed = parseYaml(frontmatterText) as FrontmatterData | null
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
frontmatter = parsed
}
} catch {
// YAML parsing failed - try again after quoting problematic values
try {
const quotedText = quoteProblematicValues(frontmatterText)
const parsed = parseYaml(quotedText) as FrontmatterData | null
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
frontmatter = parsed
}
} catch (retryError) {
// Still failed - log for debugging so users can diagnose broken frontmatter
const location = sourcePath ? ` in ${sourcePath}` : ''
logForDebugging(
`Failed to parse YAML frontmatter${location}: ${retryError instanceof Error ? retryError.message : retryError}`,
{ level: 'warn' },
)
}
}
return {
frontmatter,
content,
}
}
/**
* Splits a comma-separated string and expands brace patterns.
* Commas inside braces are not treated as separators.
* Also accepts a YAML list (string array) for ergonomic frontmatter.
* @param input - Comma-separated string, or array of strings, with optional brace patterns
* @returns Array of expanded strings
* @example
* splitPathInFrontmatter("a, b") // returns ["a", "b"]
* splitPathInFrontmatter("a, src/*.{ts,tsx}") // returns ["a", "src/*.ts", "src/*.tsx"]
* splitPathInFrontmatter("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"]
* splitPathInFrontmatter(["a", "src/*.{ts,tsx}"]) // returns ["a", "src/*.ts", "src/*.tsx"]
*/
export function splitPathInFrontmatter(input: string | string[]): string[] {
if (Array.isArray(input)) {
return input.flatMap(splitPathInFrontmatter)
}
if (typeof input !== 'string') {
return []
}
// Split by comma while respecting braces
const parts: string[] = []
let current = ''
let braceDepth = 0
for (let i = 0; i < input.length; i++) {
const char = input[i]
if (char === '{') {
braceDepth++
current += char
} else if (char === '}') {
braceDepth--
current += char
} else if (char === ',' && braceDepth === 0) {
// Split here - we're at a comma outside of braces
const trimmed = current.trim()
if (trimmed) {
parts.push(trimmed)
}
current = ''
} else {
current += char
}
}
// Add the last part
const trimmed = current.trim()
if (trimmed) {
parts.push(trimmed)
}
// Expand brace patterns in each part
return parts
.filter(p => p.length > 0)
.flatMap(pattern => expandBraces(pattern))
}
/**
* Expands brace patterns in a glob string.
* @example
* expandBraces("src/*.{ts,tsx}") // returns ["src/*.ts", "src/*.tsx"]
* expandBraces("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"]
*/
function expandBraces(pattern: string): string[] {
// Find the first brace group
const braceMatch = pattern.match(/^([^{]*)\{([^}]+)\}(.*)$/)
if (!braceMatch) {
// No braces found, return pattern as-is
return [pattern]
}
const prefix = braceMatch[1] || ''
const alternatives = braceMatch[2] || ''
const suffix = braceMatch[3] || ''
// Split alternatives by comma and expand each one
const parts = alternatives.split(',').map(alt => alt.trim())
// Recursively expand remaining braces in suffix
const expanded: string[] = []
for (const part of parts) {
const combined = prefix + part + suffix
// Recursively handle additional brace groups
const furtherExpanded = expandBraces(combined)
expanded.push(...furtherExpanded)
}
return expanded
}
/**
* Parses a positive integer value from frontmatter.
* Handles both number and string representations.
*
* @param value The raw value from frontmatter (could be number, string, or undefined)
* @returns The parsed positive integer, or undefined if invalid or not provided
*/
export function parsePositiveIntFromFrontmatter(
value: unknown,
): number | undefined {
if (value === undefined || value === null) {
return undefined
}
const parsed = typeof value === 'number' ? value : parseInt(String(value), 10)
if (Number.isInteger(parsed) && parsed > 0) {
return parsed
}
return undefined
}
/**
* Validate and coerce a description value from frontmatter.
*
* Strings are returned as-is (trimmed). Primitive values (numbers, booleans)
* are coerced to strings via String(). Non-scalar values (arrays, objects)
* are invalid and are logged then omitted. Null, undefined, and
* empty/whitespace-only strings return null so callers can fall back to
* a default.
*
* @param value - The raw frontmatter description value
* @param componentName - The skill/command/agent/style name for log messages
* @param pluginName - The plugin name, if this came from a plugin
*/
export function coerceDescriptionToString(
value: unknown,
componentName?: string,
pluginName?: string,
): string | null {
if (value == null) {
return null
}
if (typeof value === 'string') {
return value.trim() || null
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value)
}
// Non-scalar descriptions (arrays, objects) are invalid — log and omit
const source = pluginName
? `${pluginName}:${componentName}`
: (componentName ?? 'unknown')
logForDebugging(`Description invalid for ${source} - omitting`, {
level: 'warn',
})
return null
}
/**
* Parse a boolean frontmatter value.
* Only returns true for literal true or "true" string.
*/
export function parseBooleanFrontmatter(value: unknown): boolean {
return value === true || value === 'true'
}
/**
* Shell values accepted in `shell:` frontmatter for .md `!`-block execution.
*/
export type FrontmatterShell = 'bash' | 'powershell'
const FRONTMATTER_SHELLS: readonly FrontmatterShell[] = ['bash', 'powershell']
/**
* Parse and validate the `shell:` frontmatter field.
*
* Returns undefined for absent/null/empty (caller defaults to bash).
* Logs a warning and returns undefined for unrecognized values — we fall
* back to bash rather than failing the skill load, matching how `effort`
* and other fields degrade.
*/
export function parseShellFrontmatter(
value: unknown,
source: string,
): FrontmatterShell | undefined {
if (value == null) {
return undefined
}
const normalized = String(value).trim().toLowerCase()
if (normalized === '') {
return undefined
}
if ((FRONTMATTER_SHELLS as readonly string[]).includes(normalized)) {
return normalized as FrontmatterShell
}
logForDebugging(
`Frontmatter 'shell: ${value}' in ${source} is not recognized. Valid values: ${FRONTMATTER_SHELLS.join(', ')}. Falling back to bash.`,
{ level: 'warn' },
)
return undefined
}