refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +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 packages/skill/skill-filesystem/README.md
README.md: 33fb550a9ca804c4f0f380232c2bfd94cc58a67e
README.zh.md: 4c09111fc2d31bf5467b57f90f65cf68e298da09

View File

@@ -0,0 +1,75 @@
# @deepseek-ai/dsh-skill-filesystem
English | [中文](README.zh.md)
Local filesystem provider for the `ctx.skills` registry.
This package implements one skill source. It scans local project, custom, and user skill roots, parses `SKILL.md` or flat Markdown skill files, and registers the provider on `ctx.skills`. The registry remains in `@deepseek-ai/dsh-skill`; the durable session catalogs and model-facing loader tool remain in `@deepseek-ai/dsh-tool-skill`.
## Plugin
Requires `ctx.skills` (`inject: ['skills']`).
### Config
| Field | Default | Meaning |
|---|---|---|
| `providerName` | `filesystem` | Unique name used to register this provider on `ctx.skills`. |
| `includeDefaultRoots` | `true` | Include project and user roots around `customSkillDirs`; set false for an isolated custom-root provider. |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | DeepSeek Harness config root resolved by [`@deepseek-ai/dsh-home-paths`](../../util/home-paths/README.md); scans `skills` under this directory. |
| `agentsHome` | `$DSH_AGENTS_HOME` or `~/.agents` | Shared agent config root scanned for compatible skills. |
| `customSkillDirs` | `[]` | Additional local skill roots scanned after project roots and before user roots. |
| `watch` | `true` | Watch host-local roots and invalidate the local provider when catalog membership or frontmatter may have changed. |
| `watchUsePolling` | `false` | Use Chokidar polling instead of native events for existing skill roots. |
| `watchStabilityThresholdMs` | `200` | Stable-write window for Chokidar `add` and `change` events. |
| `watchPollIntervalMs` | `100` | Chokidar polling/stability interval and missing-path probe interval. |
| `watchMaxProjects` | `128` | Maximum distinct project roots retained in the watcher LRU. |
| `watchFollowSymlinks` | `true` | Follow symbolic links while watching existing roots. |
## Discovery
Default roots are resolved in this provider's rank order:
| Rank | Source | Path |
|---|---|---|
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
| 300 | `custom` | `Config.customSkillDirs` |
| 400 | `user-dsh` | `<dshHome>/skills` |
| 500 | `user-agents` | `<agentsHome>/skills` |
The project root is the nearest ancestor containing `.git`; without one, the current cwd is used. The user DSH root skips its `.system` child so system-owned directories are not treated as normal user skills. `includeDefaultRoots: false` omits the project and user rows and the `$DSH_BUNDLED_SKILL_DIR` environment default while retaining explicitly configured custom and bundled roots, allowing several uniquely named isolated providers to see only their own roots. This provider supplies project and user skills; another provider may supply built-in system skills.
When `ctx.fs` is available, discovery lists roots through `ctx.fs.listDir`, reads skill files through `ctx.fs.readText`, and probes `.git` through the filesystem service. Full skill loads forward the lookup abort signal to filesystem metadata and content reads. Without a filesystem service, the provider falls back to abortable Node filesystem I/O so minimal local contexts can still load skills. Confirmed missing paths are valid empty state, malformed or non-text entries warn and skip, and unexpected discovery/read failures make the registry snapshot incomplete rather than replacing a last-good model catalog with a misleading deletion.
## Catalog Change Detection
Existing skill roots are watched with Chokidar. Before opening a native watcher, the provider realpaths the existing root or ancestor and restores the next missing segment; when `watchFollowSymlinks` is false and the root itself is a symbolic link, it preserves that final link so Chokidar can enforce the configured boundary. Discovery and diagnostics retain the configured path, while Windows cannot otherwise mix an 8.3 alias with long-form libuv events. The provider observes direct bundle directory additions/removals, flat Markdown additions/removals, and direct `SKILL.md` additions/removals/changes; `change` exists to rediscover catalog frontmatter such as `name` and `description`. Changes below `references`, `scripts`, `assets`, or other bundle resources do not invalidate the catalog. Events delivered in the same microtask batch collapse to one provider invalidation.
A root that does not exist is followed from the nearest existing ancestor one missing path segment at a time. The next segment is probed with `fs.watchFile`; once `.agents`, `skills`, or the configured root appears, observation advances until Chokidar can attach to the real root. Root deletion reverses this process, so deleting and recreating an entire skills directory remains observable. Project-scoped watchers are bounded by `watchMaxProjects`; revisiting an evicted project reattaches observation during discovery.
The first-party filesystem `write` and `edit` tools also synchronously invalidate the provider through `fs/observed` when their target could affect a watched skill entry. This fast path makes the next model step observe its own filesystem mutation without waiting for the host watcher. External IDE, Git, shell, and process changes rely on Chokidar or the missing-path probe. Existing-root watchers remain persistent until effect teardown so Chokidar owns asynchronous native error events; startup/runtime watcher failures are logged and retried. Discovery still scans readable roots and returns their candidates for direct loading, but marks the observation incomplete so it is not cached or published as an authoritative model catalog. Effect teardown closes every watcher and contains late callbacks.
## Skill Format
Skills can be single-level directory bundles (`<name>/SKILL.md`) or flat Markdown files (`<name>.md`). Nested `**/SKILL.md` discovery is deliberately excluded. Frontmatter is parsed as an open YAML object with the `yaml` package; this provider 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. Each omitted field defaults to permitting its surface, and the provider always emits both positive internal policy values, including when both keys are absent. A rejected camel-case spelling or a non-boolean invocation value drops the entire skill from discovery with a warning instead of discarding only that field or falling back to a permissive default. Invocation policy fails closed because ignoring invalid data could expose a skill on a disabled surface; wrong-typed optional `whenToUse` and `metadata` values are omitted because neither currently grants invocation.
The catalog and body have separate lifecycles. Discovery parses frontmatter to produce the summary. Every `skill(name)` load rereads and reparses the current file, so body edits need no hash, revision, cache invalidation, or proactive model notification. A frontmatter rename between discovery and loading rejects the stale name and invalidates the provider; the next catalog observation publishes the new name.
## Model Experience
Indirectly, through `dsh-tool-skill`, which renders this provider's invocable names and capped descriptions into the initial or replacement catalog and a selected current instruction body plus resource-base guidance into retained tool history while paths, provider ranks, and disabled skills remain hidden.
#### KV Cache effect
Watcher invalidation can cause the named consumer to append a replacement catalog to the existing request history. Body-only edits leave the catalog digest unchanged.
## Known Limitations and Deferred Work
- **Discovery is one level deep** — only `<root>/<name>/SKILL.md` and `<root>/<name>.md` are recognized; nested skill trees and package manifests are ignored.
- **Project scope is the nearest `.git` ancestor** — workspaces without that marker fall back to the supplied cwd, with no alternate project-root marker or monorepo subproject selection.
- **Malformed entries disappear with a warning** — the model catalog receives no per-skill diagnostic and cannot distinguish an absent skill from an invalid one; unexpected I/O failures preserve the last-good catalog instead.
- **Missing-root observation polls one path segment** — roots absent at startup use `fs.watchFile` at `watchPollIntervalMs` until Chokidar can attach, trading bounded detection latency for reliable creation detection across IDE, Git, and shell workflows.
- **No body revision protocol** — a loaded body is ordinary retained tool history; later file edits affect later calls but neither rewrite old results nor announce that the body changed.

View File

@@ -0,0 +1,75 @@
# @deepseek-ai/dsh-skill-filesystem
[English](README.md) | 中文
`ctx.skills` 注册表的本地文件系统提供方。
该包实现一个 skill(技能)来源。它扫描本地项目、自定义和用户 skill 根目录,解析 `SKILL.md` 或平铺 Markdown skill 文件,并将提供方注册到 `ctx.skills`。注册表仍位于 `@deepseek-ai/dsh-skill`;持久化会话目录和面向模型的 loader 工具仍位于 `@deepseek-ai/dsh-tool-skill`。
## 插件
需要 `ctx.skills`(`inject: ['skills']`)。
### 配置
| 字段 | 默认值 | 含义 |
|---|---|---|
| `providerName` | `filesystem` | 在 `ctx.skills` 上注册该提供方时使用的唯一名称。 |
| `includeDefaultRoots` | `true` | 在 `customSkillDirs` 周围包含项目根和用户根;设为 false 时仅使用隔离的自定义根。 |
| `dshHome` | `$DSH_HOME` 或 `~/.dsh` | 由 [`@deepseek-ai/dsh-home-paths`](../../util/home-paths/README.md) 解析的 DeepSeek Harness 配置根目录;扫描该目录下的 `skills`。 |
| `agentsHome` | `$DSH_AGENTS_HOME` 或 `~/.agents` | 为兼容 skill 扫描的共享 agent(智能体)配置根目录。 |
| `customSkillDirs` | `[]` | 在项目根目录之后、用户根目录之前扫描的其他本地 skill 根目录。 |
| `watch` | `true` | 监视宿主本地根,并在目录成员或 frontmatter 可能发生变化时使本地提供方失效。 |
| `watchUsePolling` | `false` | 对现有 skill 根使用 Chokidar 轮询,而不是原生事件。 |
| `watchStabilityThresholdMs` | `200` | Chokidar `add` 和 `change` 事件的稳定写入窗口。 |
| `watchPollIntervalMs` | `100` | Chokidar 轮询/稳定性间隔和缺失路径探测间隔。 |
| `watchMaxProjects` | `128` | watcher LRU 中保留的不同项目根数量上限。 |
| `watchFollowSymlinks` | `true` | 监视现有根时跟随符号链接。 |
## 发现
默认根按该提供方的 rank 顺序解析:
| Rank | 来源 | 路径 |
|---|---|---|
| 100 | `project-dsh` | `<projectRoot>/.dsh/skills` |
| 200 | `project-agents` | `<projectRoot>/.agents/skills` |
| 300 | `custom` | `Config.customSkillDirs` |
| 400 | `user-dsh` | `<dshHome>/skills` |
| 500 | `user-agents` | `<agentsHome>/skills` |
项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。
当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;遇到格式错误或非文本条目时,提供方会发出警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。
## 目录变更检测
现有 skill 根由 Chokidar 监视。打开原生 watcher 前,提供方会对现有根或祖先执行 realpath 解析,并拼回下一个缺失路径段;当 `watchFollowSymlinks` 为 false 且根本身是符号链接时,提供方不会展开最后这一级链接,使 Chokidar 能够强制执行配置边界。发现与诊断仍保留配置路径,从而避免 Windows 在 libuv 内部混用 8.3 别名与长格式事件路径。提供方会观察直属 bundle 目录的添加/移除、平铺 Markdown 文件的添加/移除,以及直接 `SKILL.md` 的添加/移除/变更;`change` 事件用于重新发现 `name`、`description` 等目录 frontmatter。`references`、`scripts`、`assets` 或其他 bundle 资源下的变更不会使目录失效。同一微任务批次内送达的事件会合并为一次提供方失效。
不存在的根会从最近的现有祖先开始,每次沿一个缺失路径段跟踪。系统使用 `fs.watchFile` 探测下一段;当 `.agents`、`skills` 或已配置的根出现后,观察会逐级推进,直至 Chokidar 可以附加到真实根。根删除时,该过程反向执行,因此删除再重建整个 skills 目录仍可被观察到。按项目划分的 watcher 数量受 `watchMaxProjects` 限制;再次访问已被驱逐的项目时,发现阶段会重新附加观察。
如果第一方文件系统 `write` 和 `edit` 工具的目标可能影响受监视的 skill 条目,它们还会通过 `fs/observed` 同步使提供方失效。这条快速路径让模型的下一个步骤无需等待宿主 watcher,即可观察到自身的文件系统变更。外部 IDE、Git、shell 和进程产生的变更依赖 Chokidar 或缺失路径探测。现有根的 watcher 会保持持久状态直至 effect 释放,使 Chokidar 能够接管异步原生错误事件;watcher 启动或运行时失败会被记录并触发重试。发现过程仍会扫描可读根目录,并返回其候选项供直接加载,但会将观测标记为不完整,因此不会缓存,也不会作为权威模型目录发布。effect 释放会关闭所有 watcher,并收束延迟回调。
## skill 格式
skill 可以是单层目录 bundle(`<name>/SKILL.md`),也可以是平铺 Markdown 文件(`<name>.md`)。刻意不支持发现嵌套的 `**/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` 会从面向模型的目录和 loader 中排除该 skill;`user-invocable: false` 会从面向用户的命令中排除该 skill。每个省略的字段都默认为允许对应接口调用;提供方始终输出两个正向内部策略值,即使两个键都不存在也不例外。若使用驼峰拼写或提供非布尔调用值,系统会记录警告并从发现结果中排除整个 skill,而不是只丢弃该字段或回退到宽松的默认值。调用策略校验遵循失败时默认拒绝原则,因为忽略无效数据可能会在已禁用的接口上暴露 skill;类型错误的可选 `whenToUse` 和 `metadata` 值则会被省略,因为这两个字段目前都不授予调用权限。
目录与正文具有独立的生命周期。发现阶段解析 frontmatter 以生成概述。每次 `skill(name)` 加载都会重新读取并解析当前文件,因此正文编辑不需要 hash、修订号、缓存失效或主动通知模型。若在发现与加载之间更改 frontmatter 中的名称,系统会拒绝陈旧名称并使提供方失效;下一次目录观察会发布新名称。
## 模型体验
通过 `dsh-tool-skill` 间接影响模型。它将该提供方的可调用名称和有长度上限的描述渲染到初始目录或替换目录中,并将所选的当前指令正文与资源基底指引渲染到保留的工具历史中;路径、提供方 rank 和已禁用 skill 仍被隐藏。
#### KV Cache 影响
watcher 触发的失效可促使上述消费方在现有请求历史中追加替换目录。仅涉及正文的编辑不会改变目录 digest。
## 已知限制与暂缓事项
- **发现深度为一层**:只识别 `<root>/<name>/SKILL.md` 和 `<root>/<name>.md`;忽略嵌套 skill 树和包 manifest(元数据清单)。
- **项目范围为最近 `.git` 祖先**:没有该标记的工作区回退到提供的 cwd,不支持其他项目根标记或 monorepo 子项目选择。
- **格式错误的条目会随警告消失**:模型目录不会收到每个 skill 的诊断,无法区分缺失的 skill 与无效的 skill;意外 I/O 失败则会保留最后一份可用目录。
- **缺失根观察每次轮询一个路径段**:启动时不存在的根会使用 `fs.watchFile` 按 `watchPollIntervalMs` 轮询,直至 Chokidar 可以附加;这以有界检测延迟换取跨 IDE、Git 和 shell 工作流的可靠创建检测。
- **无正文修订协议**:已加载的正文是普通的已保留工具历史;后续文件编辑会影响后续调用,但既不会改写旧结果,也不会通知正文已发生变化。

View File

@@ -0,0 +1,53 @@
{
"name": "@deepseek-ai/dsh-skill-filesystem",
"description": "Local filesystem skill provider for the DeepSeek Harness",
"version": "0.0.1-rc.2",
"publishConfig": {
"access": "restricted"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/skill/skill-filesystem"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"chokidar": "^5.0.0",
"@deepseek-ai/schemastery": "workspace:^",
"yaml": "^2.4.2"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-home-paths": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-skill-filesystem`.
* @module @deepseek-ai/dsh-skill-filesystem/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-skill-filesystem'
/** Cordis companion plugin name. */
export const name = 'skill-filesystem-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this package exposes no independent event sequence or mutable data relation
* beyond contracts enforced at its owning seam.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,444 @@
import { EventEmitter } from 'node:events'
import type { Stats } from 'node:fs'
import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SkillRegistry from '@deepseek-ai/dsh-skill'
interface FakeWatcherControl {
emitter: EventEmitter
closeCalls: number
options: Record<string, unknown>
path: string
}
interface FakeWatchFileControl {
path: string
listener(current: Stats, previous: Stats): void
}
interface FakeStatGate {
started: PromiseWithResolvers<undefined>
release: PromiseWithResolvers<undefined>
}
const watcherHarness = vi.hoisted(() => ({
watchers: [] as FakeWatcherControl[],
startupErrors: [] as Error[],
closeErrors: 0,
deferredReady: 0,
watchFiles: [] as FakeWatchFileControl[],
statGates: [] as FakeStatGate[],
}))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
...actual,
watchFile(path: string, _options: unknown, listener: FakeWatchFileControl['listener']) {
watcherHarness.watchFiles.push({ path, listener })
},
unwatchFile(path: string, listener: FakeWatchFileControl['listener']) {
const index = watcherHarness.watchFiles.findIndex(control => control.path === path && control.listener === listener)
if (index !== -1) watcherHarness.watchFiles.splice(index, 1)
},
}
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>()
return {
...actual,
async stat(...args: Parameters<typeof actual.stat>) {
const gate = watcherHarness.statGates.shift()
if (gate !== undefined) {
gate.started.resolve(undefined)
await gate.release.promise
}
return await actual.stat(...args)
},
}
})
vi.mock('chokidar', () => ({
default: {
watch(path: unknown, options: Record<string, unknown>) {
const emitter = new EventEmitter() as EventEmitter & { close(): Promise<void> }
const control: FakeWatcherControl = { emitter, closeCalls: 0, options, path: String(path) }
emitter.close = async () => {
control.closeCalls += 1
if (watcherHarness.closeErrors > 0) {
watcherHarness.closeErrors -= 1
throw new Error('close failed')
}
}
watcherHarness.watchers.push(control)
queueMicrotask(() => {
if (watcherHarness.deferredReady > 0) {
watcherHarness.deferredReady -= 1
return
}
const error = watcherHarness.startupErrors.shift()
if (error === undefined) emitter.emit('ready')
else emitter.emit('error', error)
})
return emitter
},
},
}))
const SkillFileSystem = await import('../src/index.ts')
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
}
async function writeSkill(root: string, name: string): Promise<void> {
const directory = join(root, name)
await mkdir(directory, { recursive: true })
await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: ${name}\n---\n\nBody.\n`)
}
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
beforeEach(() => {
watcherHarness.watchers.length = 0
watcherHarness.startupErrors.length = 0
watcherHarness.closeErrors = 0
watcherHarness.deferredReady = 0
watcherHarness.watchFiles.length = 0
watcherHarness.statGates.length = 0
})
describe('skill-filesystem watcher failures', () => {
it('canonicalizes an existing root before opening its native watcher', async () => {
const target = await tempDir('skill-watch-canonical-target')
const aliasParent = await tempDir('skill-watch-canonical-alias')
const alias = join(aliasParent, 'alias')
await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
const root = join(alias, '.dsh/skills')
await writeSkill(root, 'canonical-skill')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(alias, '.dsh'),
agentsHome: join(alias, '.agents'),
watch: true,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['canonical-skill'])
expect(watcherHarness.watchers[0]?.path).toBe(await realpath(root))
expect(watcherHarness.watchers[0]?.options.persistent).toBe(true)
await fiber.dispose()
})
it('preserves a symlink root when link following is disabled', async () => {
const target = await tempDir('skill-watch-link-target')
const aliasParent = await tempDir('skill-watch-link-alias')
const alias = join(aliasParent, 'skills')
await writeSkill(target, 'linked-skill')
await symlink(target, alias, process.platform === 'win32' ? 'junction' : 'dir')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
includeDefaultRoots: false,
customSkillDirs: [alias],
watch: true,
watchFollowSymlinks: false,
})
try {
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-skill'])
expect(watcherHarness.watchers[0]?.path).toBe(alias)
expect(watcherHarness.watchers[0]?.options.followSymlinks).toBe(false)
} finally {
await fiber.dispose()
await rm(aliasParent, { recursive: true, force: true })
await rm(target, { recursive: true, force: true })
}
})
it('ignores missing-path probes until the observed path actually changes', async () => {
const home = await tempDir('skill-watch-missing-stable')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
})
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
expect(watcherHarness.watchFiles).toHaveLength(2)
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
for (const control of watcherHarness.watchFiles) {
control.listener({} as Stats, {} as Stats)
}
await settle()
expect(invalidations).toBe(0)
expect(watcherHarness.watchFiles).toHaveLength(2)
await fiber.dispose()
})
it('keeps skills loadable across persistent watcher startup failures without caching them', async () => {
const home = await tempDir('skill-watch-start-error')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'retry-skill')
watcherHarness.startupErrors.push(
new Error('watch failed once'),
new Error('watch failed twice'),
new Error('watch failed three times'),
)
watcherHarness.closeErrors = 1
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchUsePolling: true,
watchFollowSymlinks: false,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'retry-skill' }],
complete: false,
})
expect((await ctx.skills.get('retry-skill'))?.content).toBe('Body.')
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'retry-skill' }],
complete: false,
})
expect(watcherHarness.watchers).toHaveLength(3)
expect(watcherHarness.watchers[0]?.options).toMatchObject({
atomic: true,
depth: 1,
followSymlinks: false,
usePolling: true,
interval: 10,
awaitWriteFinish: {
stabilityThreshold: 20,
pollInterval: 10,
},
})
await fiber.dispose()
})
it('filters events, coalesces invalidation, recovers runtime errors, and contains late callbacks', async () => {
const home = await tempDir('skill-watch-runtime-error')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'watched-skill')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['watched-skill'])
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected a root watcher')
first.emitter.emit('change', join(first.path, 'notes.txt'))
first.emitter.emit('change', join(home, 'outside.md'))
first.emitter.emit('change', join(first.path, 'watched-skill/references.md'))
first.emitter.emit('change', join(first.path, '.system/SKILL.md'))
await settle()
expect(invalidations).toBe(0)
first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md'))
first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md'))
await settle()
expect(invalidations).toBe(1)
watcherHarness.closeErrors = 1
watcherHarness.startupErrors.push(new Error('runtime rewatch failed'))
first.emitter.emit('error', new Error('runtime watch failed'))
await vi.waitFor(() => { expect(watcherHarness.watchers.length).toBeGreaterThanOrEqual(2) })
expect(invalidations).toBeGreaterThanOrEqual(2)
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'watched-skill' }],
complete: true,
})
await fiber.dispose()
first.emitter.emit('change', join(first.path, 'watched-skill/SKILL.md'))
first.emitter.emit('error', new Error('late error'))
await settle()
})
it('replaces a retained watcher when its root emits unlinkDir', async () => {
const home = await tempDir('skill-watch-root-unlink')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'removed-skill')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['removed-skill'])
const original = watcherHarness.watchers[0]
if (original === undefined) throw new Error('expected a root watcher')
await rm(root, { recursive: true })
original.emitter.emit('unlinkDir', original.path)
await vi.waitFor(() => { expect(original.closeCalls).toBeGreaterThan(0) })
await vi.waitFor(() => {
expect(watcherHarness.watchFiles.some(control => control.path === original.path)).toBe(true)
})
await fiber.dispose()
})
it('re-probes a retained root after child unlink and observes immediate recreation', async () => {
const home = await tempDir('skill-watch-root-reprobe')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'old-skill')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['old-skill'])
const original = watcherHarness.watchers[0]
if (original === undefined) throw new Error('expected a root watcher')
await rm(root, { recursive: true })
original.emitter.emit('unlink', join(original.path, 'old-skill/SKILL.md'))
await settle()
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
const missingRoot = watcherHarness.watchFiles.find(control => control.path === original.path)
expect(missingRoot).toBeDefined()
await writeSkill(root, 'recreated-skill')
missingRoot!.listener({} as Stats, {} as Stats)
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(2) })
await settle()
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['recreated-skill'])
await fiber.dispose()
})
it('settles an opening watcher when plugin disposal races its ready event', async () => {
const home = await tempDir('skill-watch-opening-dispose')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'racing-skill')
watcherHarness.deferredReady = 1
const ctx = new Context()
await ctx.plugin(SkillRegistry)
let provider!: InstanceType<typeof SkillFileSystem.FileSystemSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillFileSystem.FileSystemSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected an opening root watcher')
const disposal = provider.dispose()
await expect(discovery).rejects.toThrow('skill-filesystem watcher disposed')
await disposal
disposeProvider()
await settle()
expect(first.closeCalls).toBeGreaterThan(0)
})
it('closes an opening watcher when disposal wins the mode probe', async () => {
const home = await tempDir('skill-watch-probe-dispose')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'racing-skill')
watcherHarness.deferredReady = 1
const statGate: FakeStatGate = {
started: Promise.withResolvers<undefined>(),
release: Promise.withResolvers<undefined>(),
}
watcherHarness.statGates.push(statGate)
const ctx = new Context()
await ctx.plugin(SkillRegistry)
let provider!: InstanceType<typeof SkillFileSystem.FileSystemSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillFileSystem.FileSystemSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await statGate.started.promise
const disposal = provider.dispose()
statGate.release.resolve(undefined)
await expect(discovery).rejects.toThrow('skill-filesystem watcher disposed')
await disposal
expect(watcherHarness.watchers).toHaveLength(1)
expect(watcherHarness.watchers[0]?.closeCalls).toBeGreaterThan(0)
disposeProvider()
})
it('contains an opening watcher rejection during provider teardown', async () => {
const home = await tempDir('skill-watch-opening-reject')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'rejected-skill')
watcherHarness.deferredReady = 1
const ctx = new Context()
await ctx.plugin(SkillRegistry)
let provider!: InstanceType<typeof SkillFileSystem.FileSystemSkillProvider>
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillFileSystem.FileSystemSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchPollIntervalMs: 10,
watchStabilityThresholdMs: 20,
})
return provider
})
const discovery = provider.list({})
await vi.waitFor(() => { expect(watcherHarness.watchers).toHaveLength(1) })
const first = watcherHarness.watchers[0]
if (first === undefined) throw new Error('expected an opening root watcher')
first.emitter.emit('error', new Error('opening failed during disposal'))
const disposal = provider.dispose()
await expect(discovery).rejects.toThrow('opening failed during disposal')
await disposal
disposeProvider()
})
})

View File

@@ -0,0 +1,882 @@
import { describe, expect, it } from 'vitest'
import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { tmpdir } from 'node:os'
import { Context } from '@deepseek-ai/cordis'
import SkillRegistry from '@deepseek-ai/dsh-skill'
import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import * as SkillFileSystem from '../src/index.ts'
async function tempDir(name: string): Promise<string> {
return await import('node:fs/promises').then(fs => fs.mkdtemp(join(tmpdir(), `dsh-${name}-`)))
}
async function writeSkill(root: string, name: string, description: string, body = 'Use the skill.'): Promise<void> {
const dir = join(root, name)
await mkdir(dir, { recursive: true })
await writeFile(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
}
async function writeFlatSkill(root: string, name: string, description: string, body = 'Flat body.'): Promise<void> {
await mkdir(root, { recursive: true })
await writeFile(join(root, `${name}.md`), `---\nname: ${name}\ndescription: ${description}\n---\n\n${body}\n`)
}
class TestFileSystem extends FileSystem {
listDirCalls = 0
failResolvePaths = new Set<string>()
failStatPaths = new Set<string>()
failListDirPaths = new Set<string>()
errorResolvePaths = new Set<string>()
errorStatPaths = new Set<string>()
errorReadPaths = new Set<string>()
missingReadPaths = new Set<string>()
statOverrides = new Map<string, FsInfo | undefined>()
statSignals: Array<AbortSignal | undefined> = []
readTextSignals: Array<AbortSignal | undefined> = []
readTextOverride?: (target: FsTarget, signal?: AbortSignal) => Promise<string>
override async resolve(path: string): Promise<FsTarget> {
if (this.failResolvePaths.has(path)) throw new FsError('resolve failed', 'FS_NOT_FOUND')
if (this.errorResolvePaths.has(path)) throw new Error('resolve temporarily failed')
return { targetKey: path as never, displayPath: path }
}
override processPath(target: FsTarget): string { return String(target.targetKey) }
override fileUrl(target: FsTarget): string { return `file://${target.targetKey}` }
override contains(parent: FsTarget, child: FsTarget): boolean {
return child.targetKey === parent.targetKey || String(child.targetKey).startsWith(`${parent.targetKey}/`)
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
this.statSignals.push(signal)
if (this.failStatPaths.has(target.displayPath)) throw new FsError('stat failed', 'FS_NOT_FOUND')
if (this.errorStatPaths.has(target.displayPath)) throw new Error('stat temporarily 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)
return {
version: FsVersion(String(info.mtimeMs)),
type: info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
size: info.size,
}
} catch {
return undefined
}
}
override async lstat(path: string): Promise<FsPathInfo | undefined> {
try {
const fs = await import('node:fs/promises')
const info = await fs.lstat(path)
return {
version: FsVersion(String(info.mtimeMs)),
type: info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other',
size: info.size,
}
} catch {
return undefined
}
}
override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
this.readTextSignals.push(signal)
if (this.readTextOverride !== undefined) return await this.readTextOverride(target, signal)
if (this.missingReadPaths.has(target.displayPath)) throw new FsError('read failed', 'FS_NOT_FOUND')
if (this.errorReadPaths.has(target.displayPath)) throw new Error('read temporarily failed')
const text = await readFile(target.displayPath, 'utf8')
if (text.includes('\uFFFD')) throw new FsError('not text', 'FS_NOT_TEXT')
return text
}
override async streamText(_target: FsTarget): Promise<AsyncIterable<string>> {
throw new Error('not needed in skill tests')
}
override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise<Uint8Array> {
throw new Error('not needed in skill tests')
}
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
this.listDirCalls += 1
if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed')
const entries = await readdir(target.displayPath, { withFileTypes: true, encoding: 'utf8' })
const result: FsDirEntry[] = []
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const childPath = join(target.displayPath, entry.name)
let type: FsInfo['type'] = 'other'
let size: number | undefined
try {
const info = await stat(childPath)
type = info.isFile() ? 'file' : info.isDirectory() ? 'directory' : 'other'
size = info.isFile() ? info.size : undefined
} catch {
type = 'other'
}
result.push({
name: entry.name,
type,
target: { targetKey: childPath as never, displayPath: childPath },
version: FsVersion('test'),
...(size !== undefined ? { size } : {}),
})
}
return result
}
override async writeText(target: FsTarget, content: string): Promise<FsWriteOutcome> {
await mkdir(dirname(target.displayPath), { recursive: true })
await writeFile(target.displayPath, content)
return { operation: 'create', version: FsVersion('test'), before: null, after: content }
}
override async editText(_target: FsTarget, _request: FsEditRequest): Promise<FsEditOutcome> {
throw new Error('not needed in skill tests')
}
}
async function setupLocal(home: string, config: Partial<SkillFileSystem.Config> = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SkillRegistry)
await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
...config,
})
return ctx
}
async function waitFor<T>(read: () => Promise<T>, accept: (value: T) => boolean): Promise<T> {
const deadline = Date.now() + 5000
while (true) {
const value = await read()
if (accept(value)) return value
if (Date.now() >= deadline) throw new Error('timed out waiting for watcher state')
await new Promise(resolve => setTimeout(resolve, 20))
}
}
describe('dsh-skill-filesystem plugin exports', () => {
it('declares stable plugin metadata', () => {
expect(SkillFileSystem.name).toBe('skill-filesystem')
expect(SkillFileSystem.inject).toEqual(['skills'])
})
})
describe('FileSystemSkillProvider', () => {
it('discovers project, custom, user, and agents skill roots in priority order', async () => {
const home = await tempDir('skill-home')
const project = await tempDir('skill-project')
const custom = await tempDir('skill-custom')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(home, '.agents/skills'), 'same', 'user agents skill')
await writeSkill(join(home, '.dsh/skills'), 'same', 'user dsh skill')
await writeSkill(custom, 'same', 'custom skill')
await writeSkill(join(project, '.agents/skills'), 'same', 'project agents skill')
await writeSkill(join(project, '.dsh/skills'), 'same', 'project dsh skill')
await writeSkill(custom, 'custom-only', 'custom only')
await writeSkill(join(home, '.dsh/skills/.system'), 'hidden-system', 'hidden system')
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)).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')
expect((await ctx.skills.list({ cwd: noGit })).map(skill => skill.name)).toContain('fallback-root')
})
it('lets project skills override runtime while runtime overrides custom and user skills', async () => {
const home = await tempDir('skill-runtime-priority')
const project = await tempDir('skill-runtime-project')
const custom = await tempDir('skill-runtime-custom')
await mkdir(join(project, '.git'), { recursive: true })
await writeSkill(join(project, '.dsh/skills'), 'project-name', 'Project wins')
await writeSkill(custom, 'runtime-name', 'Custom loses')
await writeSkill(join(home, '.dsh/skills'), 'runtime-name', 'User loses')
const ctx = await setupLocal(home, { customSkillDirs: [custom] })
ctx.skills.register({
name: 'project-name',
description: 'Runtime loses to project',
content: 'Runtime body.',
source: 'runtime',
})
ctx.skills.register({
name: 'runtime-name',
description: 'Runtime wins',
content: 'Runtime body.',
source: 'runtime',
})
expect((await ctx.skills.get('project-name', { cwd: project }))?.description).toBe('Project wins')
expect((await ctx.skills.get('runtime-name', { cwd: project }))?.description).toBe('Runtime wins')
})
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.')
await writeFile(join(root, 'rich-skill.md'), [
'---',
'name: rich-skill',
'description: rich description',
'whenToUse: For richer local parsing',
'disable-model-invocation: off',
'user-invocable: YES',
'metadata:',
' owner: tests',
'---',
'',
'Rich body.',
].join('\n'))
await writeFile(join(root, 'bad.md'), '---\nname: Bad_Name\ndescription: bad\n---\n\nbad')
await writeFile(join(root, 'missing-description.md'), '---\nname: missing-description\n---\n\nbad')
await writeFile(join(root, 'no-frontmatter.md'), 'No frontmatter.')
await writeFile(join(root, 'plain-markdown.md'), '# Notes\nNot a skill.')
await writeFile(join(root, 'open-frontmatter.md'), '---\nname: open-frontmatter')
await writeFile(join(root, 'non-object.md'), '---\n[]\n---\n\nbad')
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, '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()
const flatSummary = listedBeforeDelete.find(skill => skill.name === 'flat-skill')
if (flatSummary === undefined) throw new Error('expected flat-skill')
await rm(join(root, 'flat-skill.md'))
expect(listedBeforeDelete.map(skill => skill.name)).toEqual([
'flat-skill',
'model-only-skill',
'no-trailing-body',
'rich-skill',
'user-only-skill',
])
expect(flatSummary.invocation).toEqual({ modelInvocable: true, userInvocable: true })
expect(await ctx.skills.get('flat-skill')).toBeUndefined()
expect(await ctx.skills.get('no-trailing-body')).toMatchObject({
invocation: { modelInvocable: true, userInvocable: true },
})
expect(await ctx.skills.get('user-only-skill')).toMatchObject({
invocation: { modelInvocable: false, userInvocable: true },
content: 'User-only.',
})
expect(await ctx.skills.get('model-only-skill')).toMatchObject({
invocation: { modelInvocable: true, userInvocable: false },
content: 'Model-only.',
})
expect(await ctx.skills.get('rich-skill')).toMatchObject({
whenToUse: 'For richer local parsing',
invocation: { modelInvocable: true, 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', '"true"', 'yes', 'ON', '1', '"1"']
const falsy = ['false', '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({
modelInvocable: false,
userInvocable: true,
})
}
for (const [index] of falsy.entries()) {
expect((await ctx.skills.get(`falsy-${index}`))?.invocation).toEqual({
modelInvocable: true,
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-positive-model', 'modelInvocable: false'],
['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')
await mkdir(root, { recursive: true })
await writeFile(join(root, 'crlf-skill.md'), [
'---',
'name: crlf-skill',
'description: CRLF skill',
'metadata:',
' marker: "----"',
'---',
'',
'CRLF body.',
].join('\r\n'))
await writeFile(join(root, 'block-skill.md'), [
'---',
'name: block-skill',
'description: |',
' Includes a ---- marker that is not a delimiter.',
'---',
'',
'Block body.',
].join('\n'))
const ctx = await setupLocal(home)
expect((await ctx.skills.get('crlf-skill'))?.content).toBe('CRLF body.')
expect((await ctx.skills.get('crlf-skill'))?.metadata).toEqual({ marker: '----' })
expect((await ctx.skills.get('block-skill'))?.description).toBe('Includes a ---- marker that is not a delimiter.\n')
expect((await ctx.skills.get('block-skill'))?.content).toBe('Block body.')
})
it('skips invalid YAML skill files without hiding valid siblings', async () => {
const home = await tempDir('skill-invalid-yaml')
const root = join(home, '.dsh/skills')
await writeSkill(root, 'good-skill', 'Good skill')
await writeFile(join(root, 'bad-yaml.md'), '---\nname: bad-yaml\ndescription: [unclosed\n---\n\nBad body.\n')
const ctx = await setupLocal(home)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['good-skill'])
})
it('discovers symlinked skill directories and flat files', async () => {
const home = await tempDir('skill-symlink-home')
const external = await tempDir('skill-symlink-external')
await writeSkill(external, 'linked-dir', 'Linked directory')
await writeFlatSkill(external, 'linked-flat', 'Linked flat')
await mkdir(join(home, '.dsh/skills'), { recursive: true })
await symlink(join(external, 'linked-dir'), join(home, '.dsh/skills/linked-dir'))
await symlink(join(external, 'linked-flat.md'), join(home, '.dsh/skills/linked-flat.md'))
await symlink(join(external, 'missing'), join(home, '.dsh/skills/broken-link'))
await symlink('/dev/null', join(home, '.dsh/skills/device-link'))
const ctx = await setupLocal(home)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['linked-dir', 'linked-flat'])
})
it('uses the filesystem service for discovery, reads, and project-root lookup', async () => {
const home = await tempDir('skill-read-fs')
const project = await tempDir('skill-project-root-backend')
const nestedCwd = join(project, 'packages/app')
const root = join(home, '.dsh/skills')
await mkdir(nestedCwd, { recursive: true })
await writeFlatSkill(root, 'text-skill', 'Text skill', 'Text body.')
await writeFlatSkill(root, 'resolve-fail', 'Resolve fail', 'Resolve body.')
await writeFlatSkill(root, 'stat-fail', 'Stat fail', 'Stat body.')
await mkdir(join(root, 'empty-dir'), { recursive: true })
await mkdir(join(root, 'directory-skill/SKILL.md'), { recursive: true })
await writeFile(join(root, 'binary-skill.md'), Buffer.concat([
Buffer.from('---\nname: binary-skill\ndescription: Binary skill\n---\n\n'),
Buffer.from([0xff]),
Buffer.from('\n'),
]))
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(root, 'resolve-fail.md'))
fs.failStatPaths.add(join(root, 'stat-fail.md'))
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(SkillRegistry)
await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
expect((await ctx.skills.list({ cwd: nestedCwd })).map(skill => [skill.name, skill.source])).toEqual([
['backend-root', 'project-agents'],
['text-skill', 'user-dsh'],
])
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(SkillRegistry)
await bundledCtx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
bundledSkillDir: bundled,
})
expect((await bundledCtx.skills.get('bundled-host'))?.source).toBe('bundled')
})
it('reports transient root reads as incomplete without caching an empty catalog', async () => {
const home = await tempDir('skill-transient-root')
const root = join(home, '.agents/skills')
await writeSkill(root, 'stable-skill', 'Stable skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillRegistry)
await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
})
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
fs.failListDirPaths.add(root)
const path = join(root, 'stable-skill/SKILL.md')
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
{ kind: 'present', version: FsVersion('failed-read') },
{ name: 'edit' },
)
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
fs.failListDirPaths.clear()
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
})
it('distinguishes transient filesystem entry failures from confirmed disappearance', async () => {
const home = await tempDir('skill-transient-entry')
const root = join(home, '.agents/skills')
const path = join(root, 'stable-skill/SKILL.md')
await writeSkill(root, 'stable-skill', 'Stable skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillRegistry)
await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
})
const invalidate = (): void => {
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
{ kind: 'present', version: FsVersion('entry-failure') },
{ name: 'write' },
)
}
expect((await ctx.skills.snapshot()).complete).toBe(true)
for (const failures of [fs.errorResolvePaths, fs.errorStatPaths, fs.errorReadPaths]) {
failures.add(path)
invalidate()
expect((await ctx.skills.snapshot()).complete).toBe(false)
failures.clear()
}
fs.missingReadPaths.add(path)
invalidate()
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: true })
fs.missingReadPaths.clear()
invalidate()
expect(await ctx.skills.snapshot()).toMatchObject({
skills: [{ name: 'stable-skill' }],
complete: true,
})
})
it('marks an unexpected native skill-file read failure incomplete', async () => {
const home = await tempDir('skill-native-read-failure')
const root = join(home, '.agents/skills')
await mkdir(join(root, 'broken-skill/SKILL.md'), { recursive: true })
const ctx = await setupLocal(home)
expect(await ctx.skills.snapshot()).toEqual({ skills: [], complete: false })
})
it('forwards cancellation to filesystem reads while loading a skill', async () => {
const home = await tempDir('skill-read-abort')
await writeSkill(join(home, '.dsh/skills'), 'abortable-skill', 'Abortable skill')
const ctx = new Context()
await ctx.plugin(TestFileSystem)
const fs = ctx.fs as TestFileSystem
await ctx.plugin(SkillRegistry)
await ctx.plugin(SkillFileSystem, { dshHome: join(home, '.dsh'), agentsHome: join(home, '.agents'), watch: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['abortable-skill'])
fs.statSignals = []
fs.readTextSignals = []
const started = Promise.withResolvers<undefined>()
fs.readTextOverride = async (_target, signal) => {
if (signal === undefined) throw new Error('expected the skill lookup signal')
started.resolve(undefined)
return await new Promise<string>((_resolve, reject) => {
signal.addEventListener('abort', () => {
const abortReason = signal.reason as unknown
reject(abortReason instanceof Error ? abortReason : new Error(String(abortReason)))
}, { once: true })
})
}
const controller = new AbortController()
const reason = new Error('turn cancelled')
const loading = ctx.skills.get('abortable-skill', { signal: controller.signal })
await started.promise
controller.abort(reason)
await expect(loading).rejects.toBe(reason)
expect(fs.statSignals).toEqual([controller.signal])
expect(fs.readTextSignals).toEqual([controller.signal])
})
it('refreshes additions, metadata changes, deletions, and a recreated missing root', { timeout: 20000 }, async () => {
const home = await tempDir('skill-watch-home')
const agentsRoot = join(home, '.agents/skills')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect(await ctx.skills.list()).toEqual([])
await writeSkill(agentsRoot, 'watched-skill', 'First description', 'First body.')
const added = await waitFor(
async () => await ctx.skills.list(),
skills => skills.some(skill => skill.name === 'watched-skill'),
)
expect(added.find(skill => skill.name === 'watched-skill')?.description).toBe('First description')
await writeSkill(agentsRoot, 'watched-skill', 'Second description', 'Second body.')
const changed = await waitFor(
async () => await ctx.skills.list(),
skills => skills.find(skill => skill.name === 'watched-skill')?.description === 'Second description',
)
expect(changed).toHaveLength(1)
expect((await ctx.skills.get('watched-skill'))?.content).toBe('Second body.')
await writeFlatSkill(agentsRoot, 'flat-added', 'Flat added')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('flat-added'),
)).toEqual(['flat-added', 'watched-skill'])
await rename(join(agentsRoot, 'watched-skill'), join(agentsRoot, 'renamed-skill'))
await writeSkill(agentsRoot, 'renamed-skill', 'Renamed skill')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('renamed-skill') && !names.includes('watched-skill'),
)).toEqual(['flat-added', 'renamed-skill'])
await rm(join(agentsRoot, 'renamed-skill'), { recursive: true })
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => !names.includes('renamed-skill'),
)).toEqual(['flat-added'])
await rm(join(home, '.agents'), { recursive: true })
expect(await waitFor(
async () => await ctx.skills.list(),
skills => skills.length === 0,
)).toEqual([])
await writeSkill(agentsRoot, 'recreated-skill', 'Recreated')
expect(await waitFor(
async () => (await ctx.skills.list()).map(skill => skill.name),
names => names.includes('recreated-skill'),
)).toEqual(['recreated-skill'])
} finally {
await fiber.dispose()
}
})
it('uses fs/observed as a synchronous first-party invalidation path without a watcher', async () => {
const home = await tempDir('skill-observed-home')
const root = join(home, '.agents/skills')
const ctx = await setupLocal(home)
expect(await ctx.skills.list()).toEqual([])
let invalidations = 0
ctx.on('skills/change', () => { invalidations += 1 })
await writeSkill(root, 'observed-skill', 'Observed skill')
const path = join(root, 'observed-skill/SKILL.md')
const emitObserved = (displayPath: string, actor?: object): void => {
ctx.emit(
'fs/observed',
{ targetKey: displayPath as never, displayPath },
{ kind: 'present', version: FsVersion('observed') },
actor,
)
}
emitObserved(path)
emitObserved(path, {})
emitObserved(path, { name: 'read' })
emitObserved(join(home, 'outside.md'), { name: 'write' })
emitObserved(root, { name: 'write' })
emitObserved(join(root, 'observed-skill/references/notes.md'), { name: 'write' })
emitObserved(join(home, '.dsh/skills/.system/SKILL.md'), { name: 'write' })
emitObserved(join(root, 'flat-skill.md'), { name: 'write' })
ctx.emit(
'fs/observed',
{ targetKey: path as never, displayPath: path },
{ kind: 'present', version: FsVersion('observed') },
{ name: 'edit' },
)
expect(invalidations).toBe(2)
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['observed-skill'])
})
it('bounds project watchers and re-observes an evicted project on its next lookup', async () => {
const home = await tempDir('skill-watch-lru-home')
const first = await tempDir('skill-watch-lru-first')
const second = await tempDir('skill-watch-lru-second')
await mkdir(join(first, '.git'), { recursive: true })
await mkdir(join(second, '.git'), { recursive: true })
await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project')
await writeSkill(join(second, '.agents/skills'), 'second-project', 'Second project')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [join(first, '.agents/skills')],
watch: true,
watchMaxProjects: 1,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect((await ctx.skills.list({ cwd: first })).map(skill => skill.name)).toContain('first-project')
expect((await ctx.skills.list({ cwd: second })).map(skill => skill.name)).toContain('second-project')
await writeSkill(join(first, '.agents/skills'), 'first-project', 'First project refreshed')
expect((await ctx.skills.list({ cwd: first })).find(skill => skill.name === 'first-project')?.description)
.toBe('First project refreshed')
} finally {
await fiber.dispose()
}
const noWatch = new Context()
await noWatch.plugin(SkillRegistry)
await noWatch.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: false,
watchMaxProjects: 1,
})
await noWatch.skills.list({ cwd: first })
await noWatch.skills.list({ cwd: second })
})
it('contains repeated disposal and late first-party observations', async () => {
const home = await tempDir('skill-watch-dispose')
const nonDirectoryRoot = join(home, 'not-a-directory')
await writeFile(nonDirectoryRoot, 'not a skill root')
await writeSkill(join(home, '.agents/skills'), 'disposed-skill', 'Disposed skill')
const ctx = new Context()
await ctx.plugin(SkillRegistry)
let provider!: SkillFileSystem.FileSystemSkillProvider
const disposeProvider = ctx.skills.registerProvider((control) => {
provider = new SkillFileSystem.FileSystemSkillProvider(ctx, control, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
customSkillDirs: [nonDirectoryRoot],
watch: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
return provider
})
const beforeDisposal = await provider.list({})
expect((Array.isArray(beforeDisposal) ? beforeDisposal : beforeDisposal.candidates).map(skill => skill.name))
.toEqual(['disposed-skill'])
await provider.dispose()
await provider.dispose()
provider.observeHostMutation(join(home, '.agents/skills/disposed-skill/SKILL.md'))
const afterDisposal = await provider.list({})
expect((Array.isArray(afterDisposal) ? afterDisposal : afterDisposal.candidates).map(skill => skill.name))
.toEqual(['disposed-skill'])
disposeProvider()
})
it('refreshes frontmatter through a followed skill symlink', { timeout: 10000 }, async () => {
const home = await tempDir('skill-watch-symlink-home')
const external = await tempDir('skill-watch-symlink-external')
const root = join(home, '.dsh/skills')
await writeSkill(external, 'linked-skill', 'First linked description')
await mkdir(root, { recursive: true })
await symlink(join(external, 'linked-skill'), join(root, 'linked-skill'))
const ctx = new Context()
await ctx.plugin(SkillRegistry)
const fiber = await ctx.plugin(SkillFileSystem, {
dshHome: join(home, '.dsh'),
agentsHome: join(home, '.agents'),
watch: true,
watchFollowSymlinks: true,
watchStabilityThresholdMs: 20,
watchPollIntervalMs: 10,
})
try {
expect((await ctx.skills.list())[0]?.description).toBe('First linked description')
await writeSkill(external, 'linked-skill', 'Second linked description')
const refreshed = await waitFor(
async () => await ctx.skills.list(),
skills => skills[0]?.description === 'Second linked description',
)
expect(refreshed[0]?.name).toBe('linked-skill')
} finally {
await fiber.dispose()
}
})
it('validates watcher tunables at plugin load', async () => {
const ctx = new Context()
await ctx.plugin(SkillRegistry)
await expect(ctx.plugin(SkillFileSystem, { watchMaxProjects: 0 })).rejects.toThrow('watchMaxProjects')
await expect(ctx.plugin(SkillFileSystem, { watchPollIntervalMs: 1.5 })).rejects.toThrow('watchPollIntervalMs')
await expect(ctx.plugin(SkillFileSystem, { watchStabilityThresholdMs: 0 })).rejects.toThrow('watchStabilityThresholdMs')
})
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(SkillRegistry)
await ctx.plugin(SkillFileSystem, { watch: false })
expect((await ctx.skills.list()).map(skill => skill.name)).toEqual(['env-bundled-skill', 'env-skill'])
// Isolated providers see only their explicit roots: the environment
// bundled root is a default root, so includeDefaultRoots: false must
// drop it — isolated providers never re-claim the app's builtins.
const isolated = new Context()
await isolated.plugin(SkillRegistry)
const customOnly = join(envHome, 'custom-only')
await writeSkill(customOnly, 'custom-isolated-skill', 'Custom isolated skill')
await isolated.plugin(SkillFileSystem, {
providerName: 'isolated',
includeDefaultRoots: false,
customSkillDirs: [customOnly],
watch: false,
})
expect((await isolated.skills.list()).map(skill => skill.name)).toEqual(['custom-isolated-skill'])
await isolated.fiber.dispose()
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(SkillRegistry)
SkillFileSystem.apply(empty, { watch: false })
expect(await empty.skills.list()).toEqual([])
delete process.env.DSH_AGENTS_HOME
expect(new SkillFileSystem.FileSystemSkillProvider(empty, {
signal: new AbortController().signal,
invalidate() {},
}, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('filesystem')
} finally {
if (previousDshHome === undefined) {
delete process.env.DSH_HOME
} else {
process.env.DSH_HOME = previousDshHome
}
if (previousAgentsHome === undefined) {
delete process.env.DSH_AGENTS_HOME
} 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
}
}
})
})

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../fs/fs" },
{ "path": "../../util/home-paths" },
{ "path": "../skill" },
{ "path": "../../runtime-diagnostics/invariants" }
]
}