fix: honor fs when locating skill project roots

This commit is contained in:
Yichen Jiang
2026-07-06 15:59:12 +08:00
parent 1934035d04
commit 426d65a2a2
5 changed files with 66 additions and 10 deletions

View File

@@ -35,9 +35,9 @@ Default roots are resolved in this conflict priority order:
| Extra | `Config.extraRoots` |
| System | `~/.dsh/skills/.system` |
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips `.system` during normal user scanning so system skills are read exactly once. Same-name skills keep the highest-priority copy, then model-visible summaries are sorted by skill name for stable prompts and provider prefix-cache friendliness.
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. When `ctx.fs` is available, that ancestor lookup probes `.git` through the filesystem service rather than the host filesystem so remote or sandboxed workspaces keep their own project boundary. The user DSH root skips `.system` during normal user scanning so system skills are read exactly once. Same-name skills keep the highest-priority copy, then model-visible summaries are sorted by skill name for stable prompts and provider prefix-cache friendliness.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and installs system skills through `ctx.fs.writeText`. Without a filesystem service, the package falls back to Node filesystem I/O so the service can still run in minimal test contexts. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and installs system skills through `ctx.fs.writeText`. Without a filesystem service, the package falls back to Node filesystem I/O for project-root lookup, discovery, reads, and installation so the service can still run in minimal test contexts. Missing, unreadable, or malformed skill files warn and skip instead of failing the whole request.
Discovery is memoized per resolved root set and runtime-skill revision. Runtime `register()` and active disposer calls invalidate the cache; duplicate runtime registrations do not alter the active set. Disk-only changes are picked up on the next invalidation or process restart.

View File

@@ -296,7 +296,7 @@ export class SkillService extends Service {
private async roots(cwd: string | undefined): Promise<{ project: SkillRoot[]; shared: SkillRoot[] }> {
const project: SkillRoot[] = []
if (cwd !== undefined) {
const projectRoot = await findProjectRoot(resolve(cwd))
const projectRoot = await findProjectRoot(resolve(cwd), optionalFileSystem(this.ctx))
project.push(
{ path: join(projectRoot, '.dsh/skills'), source: 'project-dsh' },
{ path: join(projectRoot, '.agents/skills'), source: 'project-agents' },
@@ -549,14 +549,11 @@ function findClosingFrontmatter(raw: string, start: number): { start: number; bo
}
}
async function findProjectRoot(cwd: string): Promise<string> {
async function findProjectRoot(cwd: string, fs: FileSystem | undefined): Promise<string> {
let current = cwd
while (true) {
try {
await access(join(current, '.git'))
if (await pathExists(join(current, '.git'), fs)) {
return current
} catch {
// Continue walking upward until a git root is found.
}
const parent = dirname(current)
if (parent === current) return cwd
@@ -564,6 +561,39 @@ async function findProjectRoot(cwd: string): Promise<string> {
}
}
async function pathExists(path: string, fs: FileSystem | undefined): Promise<boolean> {
if (fs !== undefined) {
return await pathExistsInFileSystem(path, fs)
}
return await pathExistsInNode(path)
}
async function pathExistsInFileSystem(path: string, fs: FileSystem): Promise<boolean> {
let target
try {
target = await fs.resolve(path)
} catch {
// A backend may reject or hide this candidate; continue walking upward.
return false
}
try {
return await fs.stat(target) !== undefined
} catch {
// Transient stat failures make only this git-root candidate unusable.
return false
}
}
async function pathExistsInNode(path: string): Promise<boolean> {
try {
await access(path)
return true
} catch {
// Missing host paths are expected while walking toward the filesystem root.
return false
}
}
function normalizeSkill(skill: SkillRegistration): SkillDefinition {
if (!SKILL_NAME.test(skill.name)) throw new Error(`invalid skill name "${skill.name}"`)
if (skill.description.length === 0) throw new Error(`skill "${skill.name}" requires a description`)

View File

@@ -25,6 +25,7 @@ class TestFileSystem extends FileSystem {
listDirCalls = 0
failResolvePaths = new Set<string>()
failStatPaths = new Set<string>()
statOverrides = new Map<string, FsInfo | undefined>()
override async resolve(path: string): Promise<FsTarget> {
if (this.failResolvePaths.has(path)) throw new Error('resolve failed')
@@ -33,6 +34,7 @@ class TestFileSystem extends FileSystem {
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
if (this.failStatPaths.has(target.displayPath)) throw new Error('stat failed')
if (this.statOverrides.has(target.displayPath)) return this.statOverrides.get(target.displayPath)
try {
const fs = await import('node:fs/promises')
const info = await fs.stat(target.displayPath)
@@ -458,6 +460,30 @@ describe('SkillService', () => {
expect(await ctx.skills.get('binary-skill')).toBeUndefined()
})
it('uses the filesystem service when locating a workspace project root', async () => {
const home = await tempDir('skill-project-root-fs')
const project = await tempDir('skill-project-root-backend')
const nestedCwd = join(project, 'packages/app')
await mkdir(nestedCwd, { recursive: true })
await writeSkill(join(project, '.agents/skills'), 'backend-root', 'Backend root skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
fs.failResolvePaths.add(join(nestedCwd, '.git'))
fs.failStatPaths.add(join(project, 'packages/.git'))
fs.statOverrides.set(join(project, '.git'), {
version: FsVersion('virtual-git'),
type: 'directory',
size: 0,
})
await ctx.plugin(SkillService, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), installSystemSkills: false })
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
['backend-root', 'project-agents'],
])
})
it('degrades when bundled system skill installation fails', async () => {
const home = await tempDir('skill-install-fail')
await writeFile(join(home, '.dsh'), 'not a directory')