feat(fs): add directory listing seam

This commit is contained in:
Yichen Jiang
2026-07-03 14:36:20 +08:00
parent d8ea99756d
commit 803ed4bd95
15 changed files with 268 additions and 23 deletions

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-fs-local
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the six `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -15,6 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other `readdir` I/O failures report `FS_IO_ERROR`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).

View File

@@ -20,8 +20,8 @@
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
@@ -55,6 +55,12 @@ function errorMessage(error: unknown): string {
}
/* v8 ignore stop */
/* v8 ignore start -- requires permission/kernel failures from readdir after a successful directory stat. */
function isPermissionError(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM')
}
/* v8 ignore stop */
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
@@ -112,6 +118,15 @@ export interface PathInfo {
size: number
}
/** One local directory child with a resolved target and cheap metadata. */
export interface LocalDirEntry {
name: string
type: 'file' | 'directory' | 'other'
target: LocalTarget
version?: FsVersion
size?: number
}
/**
* Resolve a path to its absolute display path and realpath identity. Relative
* paths are based on `cwd`. When the file itself does not yet exist, the
@@ -172,6 +187,51 @@ export async function probe(absolutePath: string): Promise<PathInfo | null> {
}
}
// --- Directory listing ---
/* v8 ignore start -- requires permission/kernel failures from readdir after a successful directory stat. */
function listingIoError(displayPath: string, error: unknown): FsError {
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
}
/* v8 ignore stop */
/**
* List direct children of a directory in stable name order. Each child includes
* a resolved target plus stat metadata when still available; file contents are
* never read.
*/
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
throwIfAborted(signal, 'list')
const info = await probe(target.targetKey)
if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
let entries: Dirent[]
try {
entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' })
} catch (error: unknown) {
/* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */
throw listingIoError(target.displayPath, error)
}
throwIfAborted(signal, 'list')
return await Promise.all(entries
.sort((left, right) => left.name.localeCompare(right.name))
.map(async (entry): Promise<LocalDirEntry> => {
const childTarget = await resolveLocalTarget(target.displayPath, entry.name)
const childInfo = await probe(childTarget.targetKey)
return {
name: entry.name,
type: childInfo?.type ?? 'other',
target: childTarget,
...(childInfo ? { version: childInfo.version } : {}),
...(childInfo?.type === 'file' ? { size: childInfo.size } : {}),
}
}))
}
// --- Reading ---
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {

View File

@@ -1,6 +1,6 @@
/**
* Local-filesystem implementation of the `ctx.fs` provider seam.
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven
* text-storage primitives with the host filesystem via
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
* `realpath`, so the stable `targetKey` is the real file identity (two input
@@ -17,6 +17,7 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -26,6 +27,7 @@ import type {
} from '@deepseek-ai/dsh-fs'
import {
applyLiteralEdit,
listDirectory,
probe,
readForEdit,
readWholeText,
@@ -39,6 +41,7 @@ import type { FsIoInternals } from './fsio.ts'
export {
STREAM_MIN_SIZE,
applyLiteralEdit,
listDirectory,
probe,
readForEdit,
readWholeText,
@@ -47,7 +50,7 @@ export {
streamWholeText,
writeFileAtomic,
} from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
/** Configuration for the local filesystem backend. */
export interface Config {
@@ -117,6 +120,17 @@ export class LocalFileSystem extends FileSystem {
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
}
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
return entries.map(entry => ({
name: entry.name,
type: entry.type,
target: { inputPath: entry.target.displayPath, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
...(entry.version !== undefined ? { version: entry.version } : {}),
...(entry.size !== undefined ? { size: entry.size } : {}),
}))
}
override async writeText(
target: FsTarget,
content: string,

View File

@@ -7,7 +7,7 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
@@ -118,6 +118,50 @@ describe('readText / streamText', () => {
})
})
describe('listDir', () => {
it('lists files and directories in stable name order with resolved child targets', async () => {
await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
await writeFile(join(dir, 'skills', 'zeta.md'), 'zeta')
await writeFile(join(dir, 'skills', 'alpha.md'), 'alpha')
await symlink(join(dir, 'skills', 'missing-target'), join(dir, 'skills', 'broken-link'))
const entries = await fs.listDir(await fs.resolve('skills'))
expect(entries.map(entry => [entry.name, entry.type])).toEqual([
['alpha.md', 'file'],
['broken-link', 'other'],
['dir-skill', 'directory'],
['zeta.md', 'file'],
])
expect(entries.map(entry => entry.target.displayPath)).toEqual([
join(dir, 'skills', 'alpha.md'),
join(dir, 'skills', 'broken-link'),
join(dir, 'skills', 'dir-skill'),
join(dir, 'skills', 'zeta.md'),
])
const materializedEntries = entries.filter(entry => entry.version !== undefined)
expect(materializedEntries.map(entry => entry.target.targetKey))
.toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath))))
expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
})
it('reports a missing directory as FS_NOT_FOUND', async () => {
await expect(fs.listDir(await fs.resolve('missing'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
})
it('reports a file target as FS_NOT_DIRECTORY', async () => {
await writeFile(join(dir, 'a.txt'), 'text')
await expect(fs.listDir(await fs.resolve('a.txt'))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
})
it('honors a pre-aborted signal', async () => {
await mkdir(join(dir, 'skills'), { recursive: true })
await expect(fs.listDir(await fs.resolve('skills'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('writeText', () => {
it('createIfAbsent creates a new file', async () => {
const target = await fs.resolve('new.txt')

View File

@@ -12,6 +12,7 @@ import { join } from 'node:path'
import { createServer } from 'node:net'
import {
applyLiteralEdit,
listDirectory,
probe,
readForEdit,
readWholeText,
@@ -145,6 +146,36 @@ describe('probe', () => {
})
})
describe('listDirectory', () => {
it('lists direct children in stable order without reading content', async () => {
const root = join(dir, 'skills')
await mkdir(join(root, 'dir-skill'), { recursive: true })
await writeFile(join(root, 'zeta.md'), 'zeta')
await writeFile(join(root, 'alpha.md'), 'alpha')
await symlink(join(root, 'missing-target'), join(root, 'broken-link'))
const entries = await listDirectory(localTarget(root))
expect(entries.map(entry => [entry.name, entry.type])).toEqual([
['alpha.md', 'file'],
['broken-link', 'other'],
['dir-skill', 'directory'],
['zeta.md', 'file'],
])
expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
})
it('rejects missing, non-directory, and aborted listing requests', async () => {
await expect(listDirectory(localTarget(join(dir, 'missing')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
const file = join(dir, 'a.txt')
await writeFile(file, 'hi')
await expect(listDirectory(localTarget(file))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
await expect(listDirectory(localTarget(dir), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('readWholeText', () => {
it('reads a small file', async () => {
const file = join(dir, 'a.txt')

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-fs
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
@@ -15,7 +15,7 @@ A future sandboxed, virtual, or remote backend implements this interface and the
## Service API (`ctx.fs`)
A backend subclasses `FileSystem` and implements six primitives.
A backend subclasses `FileSystem` and implements seven primitives.
| Member | Semantics |
|---|---|
@@ -23,6 +23,7 @@ A backend subclasses `FileSystem` and implements six primitives.
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
@@ -40,4 +41,4 @@ This package declares three events (see the generated [catalog](../../../docs/co
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.

View File

@@ -59,6 +59,7 @@
import { Context, Service } from 'cordis'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -76,6 +77,7 @@ export {
export type {
FsEditOutcome,
FsEditRequest,
FsDirEntry,
FsErrorCode,
FsInfo,
FsTarget,
@@ -131,7 +133,7 @@ declare module 'cordis' {
}
/**
* Abstract filesystem provider service. Subclass, implement the six text-storage
* Abstract filesystem provider service. Subclass, implement the seven storage
* primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one
* implementation per context; loading a second throws, cordis' standard
* duplicate-service behavior).
@@ -145,6 +147,11 @@ declare module 'cordis' {
* - {@link readText}/{@link streamText} read the whole regular text file (the
* stream for large files); both own regular-file checks, UTF-8 decoding,
* binary/NUL rejection, and `FS_NOT_TEXT`.
* - {@link listDir} returns direct children of a directory in stable name order
* with resolved child targets and cheap metadata only. It never reads file
* contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw
* `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and
* other backend I/O failures throw `FS_IO_ERROR`.
* - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL:
* omit it for an unconditional create-or-overwrite (the bare-provider default),
* or supply a {@link FsWriteIntent} to guard the write.
@@ -190,6 +197,12 @@ export abstract class FileSystem extends Service {
*/
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
/**
* List direct children of a directory in stable name order. Returns resolved
* child targets plus cheap metadata only; never reads file contents.
*/
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
/**
* Create or fully replace a UTF-8 text file atomically. `expected` is the
* create-vs-replace decision and stale guard when supplied; OMITTING it is an

View File

@@ -78,6 +78,23 @@ export interface FsInfo {
size?: number
}
/**
* One direct child returned by {@link FileSystem.listDir}. Listing returns
* metadata and resolved targets only; it must not read file contents.
*/
export interface FsDirEntry {
/** Basename of the child inside the listed directory. */
name: string
/** Whether the child is a regular file, a directory, or something else. */
type: 'file' | 'directory' | 'other'
/** Resolved child target for follow-up operations. */
target: FsTarget
/** Opaque freshness token when the backend can report metadata cheaply. */
version?: FsVersion
/** Byte size of a regular file, when the backend can report it. */
size?: number
}
/**
* The explicit intent of a guarded {@link FileSystem.writeText} call.
* `createIfAbsent` creates a missing target and rejects an existing one with
@@ -130,8 +147,11 @@ export interface FsEditOutcome {
*/
export type FsErrorCode =
| 'FS_NOT_FOUND'
| 'FS_NOT_DIRECTORY'
| 'FS_NOT_TEXT'
| 'FS_NOT_REGULAR_FILE'
| 'FS_PERMISSION_DENIED'
| 'FS_IO_ERROR'
| 'FS_STALE_VERSION'
| 'FS_NOT_OBSERVED'
| 'FS_AMBIGUOUS_EDIT'

View File

@@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -17,7 +18,7 @@ import type {
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
/** A minimal in-memory fake implementing the six provider primitives. */
/** A minimal in-memory fake implementing the seven provider primitives. */
class FakeFileSystem extends FileSystem {
files = new Map<string, string>()
@@ -38,6 +39,18 @@ class FakeFileSystem extends FileSystem {
const content = await this.readText(target)
return (async function* () { yield content })()
}
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY')
return [
{
name: 'alpha.md',
type: 'file',
target: { inputPath: 'skills/alpha.md', targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' },
size: 2,
version: FsVersion('v1'),
},
]
}
override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
const existed = this.files.has(target.targetKey)
this.files.set(target.targetKey, content)
@@ -86,6 +99,20 @@ describe('FileSystem provider seam', () => {
expect(streamed).toBe(await fs.readText(target))
})
it('listDir returns child entry targets without reading file content', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
const entries = await fs.listDir(await fs.resolve('skills'))
expect(entries).toEqual([{
name: 'alpha.md',
type: 'file',
target: { inputPath: 'skills/alpha.md', targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' },
size: 2,
version: 'v1',
}])
})
it('stat returns undefined for an absent target', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)

View File

@@ -15,6 +15,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -54,6 +55,9 @@ class FakeFs extends FileSystem {
const content = this.files.get(target.targetKey) ?? ''
return (async function* () { yield content })()
}
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
return []
}
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
this.throwIfArmed()
this.writeIntents.push(expected)