feat(skill): add invocation controls
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: c488fdc4b1d97b5aa1113e41a470484063526ded
|
||||
README.zh.md: 796c814a3c4064d545a966465caf6f99e9dd8601
|
||||
# pnpm run verify-translation-pairing --write packages/skill/skill-local/README.md
|
||||
README.md: 56e19088e075f0ce9ad76eebdd23054e538174e4
|
||||
README.zh.md: 38caf2716240700aab4220783068651c9dd851f7
|
||||
|
||||
@@ -36,7 +36,9 @@ When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, read
|
||||
|
||||
## Skill Format
|
||||
|
||||
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as YAML with the `yaml` package; it requires `name` and `description`, while `whenToUse`, `disableModelInvocation`, and `metadata` are optional. Names must be kebab-case.
|
||||
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is intentionally not part of v1. Frontmatter is parsed as an open YAML object with the `yaml` package; this provider currently interprets required `name` and `description`, plus optional `whenToUse`, `metadata`, `disable-model-invocation`, and `user-invocable`. Names must be kebab-case.
|
||||
|
||||
The two invocation fields accept YAML booleans and the case-insensitive forms `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`. `disable-model-invocation: true` excludes the skill from model-facing catalogs and loaders; `user-invocable: false` excludes it from human-facing commands. Omitted fields preserve both forms of invocation. The camel-case spellings `disableModelInvocation` and `userInvocable` are rejected with a warning instead of acting as compatibility aliases.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
|
||||
## Skill 格式
|
||||
|
||||
Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为 YAML;它要求 `name` 和 `description`,而 `whenToUse`、`disableModelInvocation` 和 `metadata` 可选。名称必须使用 kebab-case。
|
||||
Skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。v1 刻意不包含嵌套 `**/SKILL.md` 发现。Frontmatter 使用 `yaml` 包解析为开放的 YAML 对象;该提供方目前解析必填的 `name` 和 `description`,以及可选的 `whenToUse`、`metadata`、`disable-model-invocation` 和 `user-invocable`。名称必须使用 kebab-case。
|
||||
|
||||
这两个调用字段接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`。`disable-model-invocation: true` 会从面向模型的目录和加载器中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。省略字段时保留两种调用方式。系统会拒绝驼峰形式的 `disableModelInvocation` 和 `userInvocable` 并记录警告,而不会将其作为兼容别名。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isSkillName,
|
||||
type SkillCandidate,
|
||||
type SkillDefinition,
|
||||
type SkillInvocationPolicy,
|
||||
type SkillLookupOptions,
|
||||
type SkillProvider,
|
||||
type SkillSource,
|
||||
@@ -74,7 +75,7 @@ interface ParsedSkill {
|
||||
name: string
|
||||
description: string
|
||||
whenToUse?: string
|
||||
disableModelInvocation?: boolean
|
||||
invocation?: SkillInvocationPolicy
|
||||
metadata?: Record<string, unknown>
|
||||
content: string
|
||||
}
|
||||
@@ -136,7 +137,7 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
|
||||
...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {},
|
||||
source: candidate.source,
|
||||
provider: this.name,
|
||||
resourceBase: { kind: 'directory', path: locator.directory },
|
||||
@@ -184,7 +185,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
...parsed.whenToUse !== undefined ? { whenToUse: parsed.whenToUse } : {},
|
||||
...parsed.disableModelInvocation !== undefined ? { disableModelInvocation: parsed.disableModelInvocation } : {},
|
||||
...parsed.invocation !== undefined ? { invocation: parsed.invocation } : {},
|
||||
provider: 'local',
|
||||
source: root.source,
|
||||
rank: root.rank,
|
||||
@@ -263,11 +264,18 @@ async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal,
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid skill name "${name}"`)
|
||||
return undefined
|
||||
}
|
||||
let invocation
|
||||
try {
|
||||
invocation = parseInvocationPolicy(parsed.data)
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`skill file ${path} ignored: invalid invocation frontmatter: ${errorMessage(error)}`)
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
...optionalString(parsed.data, 'whenToUse'),
|
||||
...optionalBoolean(parsed.data, 'disableModelInvocation'),
|
||||
...invocation === undefined ? {} : { invocation },
|
||||
...optionalMetadata(parsed.data),
|
||||
content: parsed.body.trim(),
|
||||
}
|
||||
@@ -420,9 +428,43 @@ function optionalString(data: Record<string, unknown>, key: string): { [K in typ
|
||||
return typeof value === 'string' && value.length > 0 ? { [key]: value } : {}
|
||||
}
|
||||
|
||||
function optionalBoolean(data: Record<string, unknown>, key: string): { [K in typeof key]?: boolean } {
|
||||
function parseInvocationPolicy(data: Record<string, unknown>): SkillInvocationPolicy | undefined {
|
||||
rejectLegacyInvocationKey(data, 'disableModelInvocation', 'disable-model-invocation')
|
||||
rejectLegacyInvocationKey(data, 'userInvocable', 'user-invocable')
|
||||
const disableModelInvocation = frontmatterBoolean(data, 'disable-model-invocation')
|
||||
const userInvocable = frontmatterBoolean(data, 'user-invocable')
|
||||
if (disableModelInvocation === undefined && userInvocable === undefined) return undefined
|
||||
return {
|
||||
...disableModelInvocation === undefined ? {} : { disableModelInvocation },
|
||||
...userInvocable === undefined ? {} : { userInvocable },
|
||||
}
|
||||
}
|
||||
|
||||
function rejectLegacyInvocationKey(data: Record<string, unknown>, legacy: string, canonical: string): void {
|
||||
if (Object.hasOwn(data, legacy)) {
|
||||
throw new Error(`frontmatter field "${legacy}" is unsupported; use "${canonical}"`)
|
||||
}
|
||||
}
|
||||
|
||||
function frontmatterBoolean(data: Record<string, unknown>, key: string): boolean | undefined {
|
||||
if (!Object.hasOwn(data, key)) return undefined
|
||||
const value = data[key]
|
||||
return typeof value === 'boolean' ? { [key]: value } : {}
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 1 || value === '1') return true
|
||||
if (value === 0 || value === '0') return false
|
||||
if (typeof value === 'string') {
|
||||
switch (value.toLowerCase()) {
|
||||
case 'true':
|
||||
case 'yes':
|
||||
case 'on':
|
||||
return true
|
||||
case 'false':
|
||||
case 'no':
|
||||
case 'off':
|
||||
return false
|
||||
}
|
||||
}
|
||||
throw new TypeError(`frontmatter field "${key}" must be a boolean`)
|
||||
}
|
||||
|
||||
function optionalMetadata(data: Record<string, unknown>): { metadata?: Record<string, unknown> } {
|
||||
|
||||
@@ -200,7 +200,7 @@ describe('LocalSkillProvider', () => {
|
||||
expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
|
||||
})
|
||||
|
||||
it('parses flat skills and filters invalid or model-disabled skills from listing', async () => {
|
||||
it('parses flat skills and filters invalid skills from the invocation-neutral listing', async () => {
|
||||
const home = await tempDir('skill-flat')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeFlatSkill(root, 'flat-skill', 'flat description', 'Flat instructions.')
|
||||
@@ -209,7 +209,8 @@ describe('LocalSkillProvider', () => {
|
||||
'name: rich-skill',
|
||||
'description: rich description',
|
||||
'whenToUse: For richer local parsing',
|
||||
'disableModelInvocation: false',
|
||||
'disable-model-invocation: off',
|
||||
'user-invocable: YES',
|
||||
'metadata:',
|
||||
' owner: tests',
|
||||
'---',
|
||||
@@ -225,8 +226,10 @@ describe('LocalSkillProvider', () => {
|
||||
await writeFile(join(root, 'no-trailing-body.md'), '---\nname: no-trailing-body\ndescription: No trailing body\n---')
|
||||
await writeFile(join(root, 'notes.txt'), 'ignored')
|
||||
await mkdir(join(root, 'not-a-skill'), { recursive: true })
|
||||
await writeSkill(root, 'hidden-skill', 'hidden description', 'Hidden.')
|
||||
await writeFile(join(root, 'hidden-skill/SKILL.md'), '---\nname: hidden-skill\ndescription: hidden description\ndisableModelInvocation: true\n---\n\nHidden.\n')
|
||||
await writeSkill(root, 'user-only-skill', 'user-only description', 'User-only.')
|
||||
await writeFile(join(root, 'user-only-skill/SKILL.md'), '---\nname: user-only-skill\ndescription: user-only description\ndisable-model-invocation: true\n---\n\nUser-only.\n')
|
||||
await writeSkill(root, 'model-only-skill', 'model-only description', 'Model-only.')
|
||||
await writeFile(join(root, 'model-only-skill/SKILL.md'), '---\nname: model-only-skill\ndescription: model-only description\nuser-invocable: false\n---\n\nModel-only.\n')
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
const listedBeforeDelete = await ctx.skills.list()
|
||||
@@ -234,17 +237,88 @@ describe('LocalSkillProvider', () => {
|
||||
if (flatSummary === undefined) throw new Error('expected flat-skill')
|
||||
await writeFile(join(root, 'flat-skill.md'), '')
|
||||
|
||||
expect(listedBeforeDelete.map(skill => skill.name)).toEqual(['flat-skill', 'no-trailing-body', 'rich-skill'])
|
||||
expect(listedBeforeDelete.map(skill => skill.name)).toEqual([
|
||||
'flat-skill',
|
||||
'model-only-skill',
|
||||
'no-trailing-body',
|
||||
'rich-skill',
|
||||
'user-only-skill',
|
||||
])
|
||||
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
|
||||
expect((await ctx.skills.get('hidden-skill'))?.content).toContain('Hidden.')
|
||||
expect(await ctx.skills.get('user-only-skill')).toMatchObject({
|
||||
invocation: { disableModelInvocation: true },
|
||||
content: 'User-only.',
|
||||
})
|
||||
expect(await ctx.skills.get('model-only-skill')).toMatchObject({
|
||||
invocation: { userInvocable: false },
|
||||
content: 'Model-only.',
|
||||
})
|
||||
expect(await ctx.skills.get('rich-skill')).toMatchObject({
|
||||
whenToUse: 'For richer local parsing',
|
||||
disableModelInvocation: false,
|
||||
invocation: { disableModelInvocation: false, userInvocable: true },
|
||||
metadata: { owner: 'tests' },
|
||||
})
|
||||
expect(await ctx.skills.get('Bad_Name')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts the documented boolean spellings for invocation frontmatter', async () => {
|
||||
const home = await tempDir('skill-invocation-booleans')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await mkdir(root, { recursive: true })
|
||||
const truthy = ['true', 'TRUE', 'yes', 'ON', '1', '"1"']
|
||||
const falsy = ['false', 'FALSE', 'no', 'OFF', '0', '"0"']
|
||||
for (const [index, value] of truthy.entries()) {
|
||||
await writeFile(join(root, `truthy-${index}.md`), [
|
||||
'---',
|
||||
`name: truthy-${index}`,
|
||||
`description: Truthy ${index}`,
|
||||
`disable-model-invocation: ${value}`,
|
||||
'---',
|
||||
'',
|
||||
'Truthy.',
|
||||
].join('\n'))
|
||||
}
|
||||
for (const [index, value] of falsy.entries()) {
|
||||
await writeFile(join(root, `falsy-${index}.md`), [
|
||||
'---',
|
||||
`name: falsy-${index}`,
|
||||
`description: Falsy ${index}`,
|
||||
`user-invocable: ${value}`,
|
||||
'---',
|
||||
'',
|
||||
'Falsy.',
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
for (const [index] of truthy.entries()) {
|
||||
expect((await ctx.skills.get(`truthy-${index}`))?.invocation).toEqual({ disableModelInvocation: true })
|
||||
}
|
||||
for (const [index] of falsy.entries()) {
|
||||
expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({ userInvocable: false })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects legacy and invalid invocation frontmatter without hiding valid siblings', async () => {
|
||||
const home = await tempDir('skill-invalid-invocation')
|
||||
const root = join(home, '.dsh/skills')
|
||||
await writeSkill(root, 'good-skill', 'Good skill')
|
||||
const invalid = [
|
||||
['legacy-model', 'disableModelInvocation: true'],
|
||||
['legacy-user', 'userInvocable: false'],
|
||||
['bad-string', 'disable-model-invocation: maybe'],
|
||||
['bad-value', 'user-invocable: null'],
|
||||
] as const
|
||||
for (const [name, field] of invalid) {
|
||||
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${name}\n${field}\n---\n\nBad.\n`)
|
||||
}
|
||||
|
||||
const ctx = await setupLocal(home)
|
||||
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
|
||||
})
|
||||
|
||||
it('supports CRLF frontmatter and ignores delimiter-looking text inside YAML values', async () => {
|
||||
const home = await tempDir('skill-frontmatter-crlf')
|
||||
const root = join(home, '.dsh/skills')
|
||||
|
||||
Reference in New Issue
Block a user