fix(skill): support YAML array format for allowed-tools field (#1163)

Fixes #1021

The allowed-tools field in skill frontmatter now supports both formats:
- Space-separated string: 'allowed-tools: Read Write Edit Bash'
- YAML array: 'allowed-tools: [Read, Write, Edit, Bash]'
- Multi-line YAML array format also works

Previously, skills using YAML array format would silently fail to parse,
causing them to not appear in the <available_skills> list.

Changes:
- Updated parseAllowedTools() in loader.ts, async-loader.ts, and merger.ts
  to handle both string and string[] types
- Updated SkillMetadata type to accept string | string[] for allowed-tools
- Added 4 test cases covering all allowed-tools formats
This commit is contained in:
Thanh Nguyen
2026-01-28 14:26:34 +07:00
committed by GitHub
parent 3e32afe646
commit 48f6c5e06d
5 changed files with 143 additions and 4 deletions

View File

@@ -128,8 +128,15 @@ $ARGUMENTS
} }
} }
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined { function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined if (!allowedTools) return undefined
// Handle YAML array format: already parsed as string[]
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
// Handle space-separated string format: "Read Write Edit Bash"
return allowedTools.split(/\s+/).filter(Boolean) return allowedTools.split(/\s+/).filter(Boolean)
} }

View File

@@ -268,6 +268,123 @@ Skill body.
} finally { } finally {
process.chdir(originalCwd) process.chdir(originalCwd)
} }
})
})
describe("allowed-tools parsing", () => {
it("parses space-separated allowed-tools string", async () => {
// #given
const skillContent = `---
name: space-separated-tools
description: Skill with space-separated allowed-tools
allowed-tools: Read Write Edit Bash
---
Skill body.
`
createTestSkill("space-separated-tools", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "space-separated-tools")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("parses YAML inline array allowed-tools", async () => {
// #given
const skillContent = `---
name: yaml-inline-array
description: Skill with YAML inline array allowed-tools
allowed-tools: [Read, Write, Edit, Bash]
---
Skill body.
`
createTestSkill("yaml-inline-array", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "yaml-inline-array")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("parses YAML multi-line array allowed-tools", async () => {
// #given
const skillContent = `---
name: yaml-multiline-array
description: Skill with YAML multi-line array allowed-tools
allowed-tools:
- Read
- Write
- Edit
- Bash
---
Skill body.
`
createTestSkill("yaml-multiline-array", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "yaml-multiline-array")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("returns undefined for skill without allowed-tools", async () => {
// #given
const skillContent = `---
name: no-allowed-tools
description: Skill without allowed-tools field
---
Skill body.
`
createTestSkill("no-allowed-tools", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "no-allowed-tools")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toBeUndefined()
} finally {
process.chdir(originalCwd)
}
}) })
}) })
}) })

View File

@@ -50,8 +50,15 @@ async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | un
return undefined return undefined
} }
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined { function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined if (!allowedTools) return undefined
// Handle YAML array format: already parsed as string[]
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
// Handle space-separated string format: "Read Write Edit Bash"
return allowedTools.split(/\s+/).filter(Boolean) return allowedTools.split(/\s+/).filter(Boolean)
} }

View File

@@ -9,6 +9,14 @@ import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer" import { sanitizeModelField } from "../../shared/model-sanitizer"
import { deepMerge } from "../../shared/deep-merge" import { deepMerge } from "../../shared/deep-merge"
function parseAllowedToolsFromMetadata(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
return allowedTools.split(/\s+/).filter(Boolean)
}
const SCOPE_PRIORITY: Record<SkillScope, number> = { const SCOPE_PRIORITY: Record<SkillScope, number> = {
builtin: 1, builtin: 1,
config: 2, config: 2,
@@ -119,7 +127,7 @@ $ARGUMENTS
} }
const allowedTools = entry["allowed-tools"] || const allowedTools = entry["allowed-tools"] ||
(fileMetadata["allowed-tools"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined) (fileMetadata["allowed-tools"] ? parseAllowedToolsFromMetadata(fileMetadata["allowed-tools"]) : undefined)
return { return {
name, name,

View File

@@ -13,7 +13,7 @@ export interface SkillMetadata {
license?: string license?: string
compatibility?: string compatibility?: string
metadata?: Record<string, string> metadata?: Record<string, string>
"allowed-tools"?: string "allowed-tools"?: string | string[]
mcp?: SkillMcpConfig mcp?: SkillMcpConfig
} }