feat(skills): bundle and harden personal maintenance workflows
This commit is contained in:
@@ -2205,7 +2205,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
},
|
||||
{
|
||||
name: 'SkillSource',
|
||||
declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | (string & {});',
|
||||
declaration: 'export type SkillSource = \'project-dsh\' | \'project-agents\' | \'runtime\' | \'user-dsh\' | \'user-agents\' | \'custom\' | \'bundled\' | (string & {});',
|
||||
},
|
||||
{
|
||||
name: 'SkillSummary',
|
||||
|
||||
@@ -32,6 +32,7 @@ const PROJECT_AGENTS_RANK = 200
|
||||
const CUSTOM_RANK = 300
|
||||
const USER_DSH_RANK = 400
|
||||
const USER_AGENTS_RANK = 500
|
||||
const BUNDLED_RANK = 600
|
||||
|
||||
export const name = 'skill-local'
|
||||
export const inject = ['skills']
|
||||
@@ -44,12 +45,15 @@ export interface Config {
|
||||
agentsHome?: string
|
||||
/** Additional skill roots scanned after project roots and before user roots. */
|
||||
customSkillDirs?: string[]
|
||||
/** Bundled skill root; defaults to `$DSH_BUNDLED_SKILL_DIR`, otherwise mounts none. */
|
||||
bundledSkillDir?: string
|
||||
}
|
||||
|
||||
export const Config: Schema<Config> = z.object({
|
||||
dshHome: z.string(),
|
||||
agentsHome: z.string(),
|
||||
customSkillDirs: z.array(z.string()).default([]),
|
||||
bundledSkillDir: z.string(),
|
||||
})
|
||||
|
||||
interface SkillRoot {
|
||||
@@ -57,6 +61,7 @@ interface SkillRoot {
|
||||
source: SkillSource
|
||||
rank: number
|
||||
skipSystem?: boolean
|
||||
trustedHost?: boolean
|
||||
}
|
||||
|
||||
interface SkillRootEntry {
|
||||
@@ -91,11 +96,14 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
private readonly dshHome: string
|
||||
private readonly agentsHome: string
|
||||
private readonly customSkillDirs: string[]
|
||||
private readonly bundledSkillDir: string | undefined
|
||||
|
||||
constructor(private readonly ctx: Context, config: Config = {}) {
|
||||
this.dshHome = resolveDshHome(config.dshHome)
|
||||
this.agentsHome = resolve(config.agentsHome ?? process.env.DSH_AGENTS_HOME ?? join(homedir(), '.agents'))
|
||||
this.customSkillDirs = (config.customSkillDirs ?? []).map(root => resolve(root))
|
||||
const bundledSkillDir = config.bundledSkillDir ?? process.env.DSH_BUNDLED_SKILL_DIR
|
||||
this.bundledSkillDir = bundledSkillDir === undefined ? undefined : resolve(bundledSkillDir)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,7 +130,7 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
*/
|
||||
async get(candidate: SkillCandidate, options: SkillLookupOptions): Promise<SkillDefinition | undefined> {
|
||||
const locator = candidate.locator as LocalLocator
|
||||
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal)
|
||||
const parsed = await parseSkillFile(locator.path, this.ctx, options.signal, candidate.source === 'bundled')
|
||||
if (parsed === undefined) return undefined
|
||||
return {
|
||||
name: parsed.name,
|
||||
@@ -151,6 +159,9 @@ export class LocalSkillProvider implements SkillProvider {
|
||||
...this.customSkillDirs.map(path => ({ path, source: 'custom' as const, rank: CUSTOM_RANK })),
|
||||
{ path: join(this.dshHome, 'skills'), source: 'user-dsh', rank: USER_DSH_RANK, skipSystem: true },
|
||||
{ path: join(this.agentsHome, 'skills'), source: 'user-agents', rank: USER_AGENTS_RANK },
|
||||
...this.bundledSkillDir === undefined
|
||||
? []
|
||||
: [{ path: this.bundledSkillDir, source: 'bundled' as const, rank: BUNDLED_RANK, trustedHost: true }],
|
||||
)
|
||||
return roots
|
||||
}
|
||||
@@ -167,7 +178,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
|
||||
? { path: entry.path, directory: root.path }
|
||||
: undefined
|
||||
if (locator === undefined) continue
|
||||
const parsed = await parseSkillFile(locator.path, ctx)
|
||||
const parsed = await parseSkillFile(locator.path, ctx, undefined, root.trustedHost === true)
|
||||
if (parsed === undefined) continue
|
||||
skills.push({
|
||||
name: parsed.name,
|
||||
@@ -188,7 +199,7 @@ async function discoverRoot(root: SkillRoot, ctx: Context): Promise<SkillCandida
|
||||
|
||||
async function listSkillRootEntries(root: SkillRoot, ctx: Context): Promise<SkillRootEntry[]> {
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) return await listSkillRootEntriesFromFileSystem(root, fs)
|
||||
if (fs !== undefined && root.trustedHost !== true) return await listSkillRootEntriesFromFileSystem(root, fs)
|
||||
return await listSkillRootEntriesFromNode(root, ctx)
|
||||
}
|
||||
|
||||
@@ -225,8 +236,8 @@ async function listSkillRootEntriesFromNode(root: SkillRoot, ctx: Context): Prom
|
||||
return result
|
||||
}
|
||||
|
||||
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal): Promise<ParsedSkill | undefined> {
|
||||
const raw = await readSkillText(ctx, path, signal)
|
||||
async function parseSkillFile(path: string, ctx: Context, signal?: AbortSignal, trustedHost = false): Promise<ParsedSkill | undefined> {
|
||||
const raw = await readSkillText(ctx, path, signal, trustedHost)
|
||||
signal?.throwIfAborted()
|
||||
if (raw === undefined) {
|
||||
return undefined
|
||||
@@ -266,10 +277,10 @@ function optionalFileSystem(ctx: Context): FileSystem | undefined {
|
||||
return ctx.get('fs')
|
||||
}
|
||||
|
||||
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal): Promise<string | undefined> {
|
||||
async function readSkillText(ctx: Context, path: string, signal?: AbortSignal, trustedHost = false): Promise<string | undefined> {
|
||||
signal?.throwIfAborted()
|
||||
const fs = optionalFileSystem(ctx)
|
||||
if (fs !== undefined) {
|
||||
if (fs !== undefined && !trustedHost) {
|
||||
return await readSkillTextFromFileSystem(ctx, fs, path, signal)
|
||||
}
|
||||
try {
|
||||
|
||||
@@ -149,15 +149,23 @@ describe('LocalSkillProvider', () => {
|
||||
await writeSkill(custom, 'custom-only', 'custom only')
|
||||
await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
|
||||
|
||||
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
|
||||
const bundled = await tempDir('skill-bundled')
|
||||
await writeSkill(bundled, 'bundled-only', 'bundled skill')
|
||||
await writeSkill(bundled, 'same', 'bundled skill')
|
||||
const ctx = await setupLocal(home, { customSkillDirs: [custom], bundledSkillDir: bundled })
|
||||
|
||||
const skills = await ctx.skills.list({ cwd: join(project, 'src') })
|
||||
expect(skills.map(skill => [skill.name, skill.description])).toEqual([
|
||||
['custom-only', 'custom only'],
|
||||
['same', 'project dsh skill'],
|
||||
expect(skills.map(skill => skill.name)).toEqual([
|
||||
'bundled-only',
|
||||
'custom-only',
|
||||
'same',
|
||||
])
|
||||
expect(skills.find(skill => skill.name === 'custom-only')?.description).toBe('custom only')
|
||||
expect(skills.find(skill => skill.name === 'same')?.description).toBe('project dsh skill')
|
||||
expect(skills.find(skill => skill.name === 'same')?.source).toBe('project-dsh')
|
||||
expect(skills.find(skill => skill.name === 'hidden-system')).toBeUndefined()
|
||||
expect(skills.find(skill => skill.name === 'bundled-only')).toMatchObject({ source: 'bundled' })
|
||||
expect((await ctx.skills.get('bundled-only'))?.content).toBe('Use the skill.')
|
||||
|
||||
const noGit = await tempDir('skill-no-git')
|
||||
await writeSkill(join(noGit, '.dsh/skills'), 'fallback-root', 'Fallback root')
|
||||
@@ -335,6 +343,20 @@ describe('LocalSkillProvider', () => {
|
||||
])
|
||||
expect(fs.listDirCalls).toBeGreaterThan(0)
|
||||
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
|
||||
|
||||
const bundled = await tempDir('skill-backend-bundled')
|
||||
await writeSkill(bundled, 'bundled-host', 'Bundled host skill')
|
||||
const bundledCtx = new Context()
|
||||
await bundledCtx.plugin(TestFileSystem)
|
||||
const bundledFs = bundledCtx.fs as TestFileSystem
|
||||
bundledFs.failResolvePaths.add(bundled)
|
||||
await bundledCtx.plugin(SkillService)
|
||||
await bundledCtx.plugin(SkillLocal, {
|
||||
dshHome: join(home, '.dsh'),
|
||||
agentsHome: join(home, '.agents'),
|
||||
bundledSkillDir: bundled,
|
||||
})
|
||||
expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
|
||||
})
|
||||
|
||||
it('forwards cancellation to filesystem reads while loading a skill', async () => {
|
||||
@@ -375,17 +397,22 @@ describe('LocalSkillProvider', () => {
|
||||
it('uses default home root resolution without exposing builtin skills', async () => {
|
||||
const previousDshHome = process.env.DSH_HOME
|
||||
const previousAgentsHome = process.env.DSH_AGENTS_HOME
|
||||
const previousBundledSkillDir = process.env.DSH_BUNDLED_SKILL_DIR
|
||||
const envHome = await tempDir('skill-env-home')
|
||||
try {
|
||||
process.env.DSH_HOME = join(envHome, '.dsh')
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, '.agents')
|
||||
const bundled = join(envHome, 'bundled-skills')
|
||||
process.env.DSH_BUNDLED_SKILL_DIR = bundled
|
||||
await writeSkill(join(envHome, '.dsh/skills'), 'env-skill', 'Env skill')
|
||||
await writeSkill(bundled, 'env-bundled-skill', 'Env bundled skill')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SkillService)
|
||||
await ctx.plugin(SkillLocal)
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-skill'])
|
||||
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
|
||||
|
||||
process.env.DSH_HOME = join(envHome, 'empty-dsh')
|
||||
delete process.env.DSH_BUNDLED_SKILL_DIR
|
||||
process.env.DSH_AGENTS_HOME = join(envHome, 'empty-agents')
|
||||
const empty = new Context()
|
||||
await empty.plugin(SkillService)
|
||||
@@ -405,6 +432,11 @@ describe('LocalSkillProvider', () => {
|
||||
} else {
|
||||
process.env.DSH_AGENTS_HOME = previousAgentsHome
|
||||
}
|
||||
if (previousBundledSkillDir === undefined) {
|
||||
delete process.env.DSH_BUNDLED_SKILL_DIR
|
||||
} else {
|
||||
process.env.DSH_BUNDLED_SKILL_DIR = previousBundledSkillDir
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,7 +28,7 @@ export function isSkillName(name: string): boolean {
|
||||
}
|
||||
|
||||
/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */
|
||||
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {})
|
||||
export type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | 'bundled' | (string & {})
|
||||
|
||||
/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */
|
||||
export type SkillResourceBase =
|
||||
|
||||
Reference in New Issue
Block a user