opencode-fs-plugin
v1.5.1
Published
OpenCode 2 plugin: Linux-style filesystem, text, and HTTP(S) tools under the fs namespace.
Readme
opencode-fs-plugin
An OpenCode 2 plugin that adds a Linux-style filesystem, text, and HTTP(S)
toolkit under the fs namespace. It is a maintained fork of
kiu345/opencode-fs-plugin,
rewritten with async I/O, permission integration, a symlink-aware sandbox, and
unit, end-to-end and coverage tests.
Tools
| Native command | Tool | Main options |
|---|---|---|
| mkdir | fs.mkdir | parents, mode, verbose |
| rmdir | fs.rmdir | parents, ignoreNonEmpty, verbose |
| rm | fs.rm | recursive, force, dir, verbose |
| cp | fs.cp | recursive, archive, force, noClobber, update, preserve, dereference, verbose |
| mv | fs.mv | force, noClobber, update, verbose |
| ls | fs.ls | all, almostAll, long, human, recursive, directory, inode, classify, size, numericIds, noGroup, noOwner, slash, quote, versionSort, extensionSort, fullTime, timeStyle, hide, ignore, groupDirectoriesFirst, sortBy, reverse, onePerLine |
| stat | fs.stat | follow, format, terse, filesystem |
| touch | fs.touch | accessOnly, modifyOnly, noCreate, date, timestamp, reference, noDereference |
| truncate | fs.truncate | size, reference, noCreate, noDereference |
| ln | fs.ln | symbolic, force, noDereference, relative, verbose |
| readlink | fs.readlink | mode (raw, canonical, existing, missing), noNewline |
| chmod | fs.chmod | octal/symbolic mode, reference, recursive, noDereference, verbose, changes, silent |
| sha256sum/md5sum | fs.hash | algorithm, tag; hashes files or stdin |
| du | fs.du | summarize, all, human, total, maxDepth, follow, oneFileSystem |
| df | fs.df | human, inodes, printType, all |
| find | fs.find | name, iname, pathPattern, lname, ilname, regex, iregex, type, size, mtime, mmin, maxDepth, minDepth, empty, perm, uid, gid, user, group, links, inum, samefile, fstype, newer, newerXY, printf, follow, print0, sort |
| cat | fs.cat | number, numberNonBlank, squeezeBlank, showEnds, showTabs, showNonPrinting |
| head | fs.head | lines, bytes, quiet, verbose |
| tail | fs.tail | lines, bytes, fromStart, quiet, verbose, follow, timeoutMs, intervalMs, maxBytes |
| tee | fs.tee | append, content |
| wc | fs.wc | lines, words, bytes, chars, maxLineLength |
| sort | fs.sort | numeric, generalNumeric, humanNumeric, version, reverse, unique, ignoreCase, dictionary, ignoreNonPrinting, ignoreLeadingBlanks, stable, check, checkQuiet, zeroTerminated, keys, field, separator |
| uniq | fs.uniq | count, repeated, uniqueOnly, ignoreCase, skipFields, skipChars |
| cut | fs.cut | fields, characters, bytes, delimiter, outputDelimiter, complement, onlyDelimited |
| tr | fs.tr | set1, set2, delete, squeeze, complement; ranges and classes |
| sed | fs.sed | script, expressions, inPlace, extendedRegexp, quiet; s/// subset |
| grep | fs.grep | recursive, include, exclude, ignoreCase, invert, lineNumbers, withFilename, noFilename, filesWithMatches, filesWithoutMatch, count, onlyMatching, word, line, fixed, context, before, after, maxCount, noMessages |
| tar | fs.tar | create/extract/list, gzip, directory, stripComponents, exclude, verbose, input, member |
| zip | fs.zip | archive, paths, level, input, member |
| unzip | fs.unzip | extract/list, directory, overwrite, noOverwrite |
| gzip | fs.gzip | keep, force, stdout, level |
| gunzip | fs.gunzip | keep, force, stdout, suffix |
| mktemp | fs.mktemp | template, directory, tmpdir, suffix |
| trash | fs.trash | XDG home trash |
| diff | fs.diff | unified, brief, recursive, ignoreWhitespace, ignoreCase, exclude |
| chown | fs.chown | user/uid, group/gid, recursive, noDereference |
| ps | fs.ps | pids, user, command, sortBy, limit, noHeader, format |
| pgrep | fs.pgrep | pattern, full, ignoreCase, user, newest, oldest, count, delimiter |
| kill | fs.kill | pids, signal, dryRun; pid 0/1 and groups rejected |
| file | fs.file | paths, mime; content magic detection |
| base64 | fs.base64 | decode, wrap, ignoreGarbage |
| od | fs.od | offset, length, width |
| strings | fs.strings | minLength |
| id | fs.id | only (user, group, groups) |
| uname | fs.uname | kernel, nodename, release, version, machine |
| uptime | fs.uptime | pretty, loadAverage |
| date | fs.date | value, utc, format (iso, unix, rfc, date) |
| findmnt | fs.findmnt | paths, fsType, noHeader |
| mountpoint | fs.mountpoint | paths, quiet |
| tac | fs.tac | reverse lines |
| rev | fs.rev | reverse characters per line |
| nl | fs.nl | all, start, width, separator |
| paste | fs.paste | delimiters, serial |
| comm | fs.comm | suppress1/2/3, outputDelimiter |
| split | fs.split | lines/bytes, prefix, suffixLength, numeric, additionalSuffix |
| shuf | fs.shuf | headCount, zeroTerminated |
| fold | fs.fold | width, spaces |
| sync | fs.sync | paths; file/directory fsync |
| dd | fs.dd | input, output, blockSize, count, skip, seek, status |
| cpio | fs.cpio | create/list/extract (newc), directory, members, input, member, verbose |
| ar | fs.ar | create/list/extract, directory, members |
| HTTP(S) | fs.http_request | url, method, headers, body, json, output, checksum, maxBytes, timeoutMs, failOnError, followRedirects, maxRedirects |
| shell pipe | fs.pipe | stages, input, output, append |
Pipe
fs.pipe joins registered tools through byte buffers. Stages run in order;
only pipeable tools can be used. output and append behave like a final
tee.
await tools.fs.pipe({
stages: [
{ tool: "cat", args: { paths: ["app.log"] } },
{ tool: "grep", args: { pattern: "ERROR", lineNumbers: true } },
{ tool: "sort", args: { unique: true } },
],
output: "errors.txt",
})New filter stages: tac, rev, nl, paste, shuf, fold, base64, od,
and strings; dd and cpio are source/sink stages like tar.
fs.http_request is a pipe source, so network data can feed the same stages:
await tools.fs.pipe({
stages: [
{ tool: "http_request", args: { url: "https://example.com/app.log" } },
{ tool: "grep", args: { pattern: "ERROR" } },
],
})Pipe stages exchange whole buffers, so use maxBytes for large responses. For
large downloads prefer fs.http_request({ output }), which streams to disk.
There is no shell, redirection operator, nested pipe, or find -exec.
Performance and concurrency
All I/O is asynchronous; the plugin never calls a synchronous fs API.
The concurrency limit defaults to os.availableParallelism(). Override it with
the plugin option concurrency or the OPENCODE_FS_CONCURRENCY environment
variable (the option wins). It bounds every parallel path, so a large tree cannot
start unbounded work:
cp/mvtree copieshash,gzip, andgunzipover multiple filesgrepfile readsfind,du,grep -r, andchmod -Rtraversal, including directory recursion and per-entry statsdiff -rfile comparisonslslong-format rendering
gzip/gunzip also use the libuv thread pool for compression; raise
UV_THREADPOOL_SIZE if compression dominates.
CPU-bound JavaScript stages (sort, sed, diff text patching) stay
single-threaded. Splitting them would need worker threads and is a deliberate
gap; their input/output cost is dominated by the async I/O above.
Background jobs
Every fs.* tool accepts background: true. The call returns immediately with
a job id and a status-file path:
const started = JSON.parse(
await tools.fs.cp({
sources: ["large-tree"],
destination: "backup",
recursive: true,
background: true,
}),
)Wait for it with fs.job, or let the agent continue and check later:
const result = await tools.fs.job({
id: started.jobId,
wait: true,
timeoutMs: 600000,
})fs.job also supports status, cancel, and list:
await tools.fs.job({ id: started.jobId }) // current status
await tools.fs.job({ id: started.jobId, cancel: true }) // abort
await tools.fs.job({ list: true }) // newest firstThe status file lets the wait plugin observe a job without coupling the two
plugins:
await tools.wait.for({
condition: {
type: "file_contains",
path: started.statusPath,
pattern: '"state":"completed"',
},
timeoutMs: 600000,
})Jobs are process-local: they are lost on an OpenCode server restart, and their
status files may remain under /tmp/opencode/fs-jobs/<server-pid>/. The manager
allows 8 concurrent jobs and cleans up finished jobs after one hour. Results
stay in memory and are returned by fs.job; the status file contains only the
job state and metadata. Background permission prompts can appear after the
starting call returns, and a denied permission fails the job.
Installing from npm
Add the registry package directly to the plugin list:
{
"plugins": ["[email protected]"]
}opencode plugin add [email protected] installs it and writes the config
entry. A bare opencode-fs-plugin follows the latest tag.
OpenCode manages the package under its cache, so the path is not stable. If you also want the packaged skill, install it into a project instead:
npm install [email protected]{
"plugins": ["./node_modules/opencode-fs-plugin"],
"skills": ["./node_modules/opencode-fs-plugin/skills"]
}Installing from a release
Download the release tarball, then install it as a normal npm package:
VERSION=1.5.1
curl -L -o "opencode-fs-plugin-${VERSION}.tgz" \
"https://github.com/masterjg/opencode-fs-plugin/releases/download/v${VERSION}/opencode-fs-plugin-${VERSION}.tgz"
npm install "./opencode-fs-plugin-${VERSION}.tgz"Point OpenCode at the installed directory:
{
"plugins": ["./node_modules/opencode-fs-plugin"]
}Relative paths resolve from the config file that contains them, so use an
absolute path in the global config. The tarball contains src/, skills/,
SPEC.md and INSTALL.md; npm installs its runtime dependencies. Do not unpack
it by hand.
OpenCode does not accept the release asset URL or a .tgz file directly in
plugins; install it with npm first and point at the installed directory.
Installing from a Git tag
Every release is tagged v<version>. Add the tag directly to the plugin list;
OpenCode installs it from Git:
{
"plugins": ["github:masterjg/opencode-fs-plugin#v1.5.1"]
}opencode plugin add 'github:masterjg/opencode-fs-plugin#v1.5.1' installs the
same spec and writes the config entry. The equivalent full URL is
git+https://github.com/masterjg/opencode-fs-plugin.git#v1.5.1. A tag is
checked for updates; use a full commit hash to pin it.
Git plugins are installed under OpenCode's cache, so their package path is not stable. If you also want the packaged skill, install with npm instead:
npm install "github:masterjg/opencode-fs-plugin#v1.5.1"Then use ./node_modules/opencode-fs-plugin in plugins and
./node_modules/opencode-fs-plugin/skills in skills.
Loading from a checkout
{
"plugins": [
"./repos/opencode-fs-plugin"
]
}The plugin exports TypeScript source directly and targets Node 22+.
Skill discovery
The plugin ships skills/filesystem/SKILL.md. OpenCode does not search inside a
plugin package automatically, so register the directory once:
{
"skills": [
"~/.config/opencode/repos/opencode-fs-plugin/skills"
]
}If the plugin was installed from the release tarball, the npm registry, or with
npm install from a Git tag into a project's node_modules, point at the
packaged skill instead:
{
"skills": [
"./node_modules/opencode-fs-plugin/skills"
]
}Relative skill paths resolve from the active working directory, not the config
file; use an absolute path for a global install. Alternatively, symlink
skills/filesystem into ~/.config/opencode/skills; OpenCode discovers that
directory automatically. See INSTALL.md for all options,
verification, and an agent-ready installation prompt.
The skill ID is filesystem; @filesystem loads it explicitly.
Permissions
Every tool asserts an action named after the tool (fs_cp, fs_rm,
fs_pipe stages, and so on). Internal paths use project-relative resources;
external targets first require external_directory on the absolute boundary.
allow always persists the same patterns as the built-in tools.
fs.http_request is allowed by default, like fetch. Its resource is the
request host (example.com:8443), so requests can be restricted per host:
{
"permissions": [
{ "action": "fs_http_request", "resource": "api.github.com", "effect": "allow" },
{ "action": "fs_http_request", "resource": "*", "effect": "ask" }
]
}Redirects re-assert the rule against each new host.
Agent rules can use wildcards:
{
"permissions": [
{ "action": "fs_*", "resource": "*", "effect": "deny" }
]
}To direct agents to this toolkit instead of the built-in shell, deny shell:
{
"permissions": [
{ "action": "shell", "resource": "*", "effect": "deny" }
]
}This removes the built-in shell tool; MCP servers and other tools that run
commands are unaffected. Apply it globally or under agents.<id>.permissions
when only some agents should be shell-free.
Safety
- The sandbox root, its ancestors,
/,$HOMEand core system directories are hard stops for destructive operations. - Paths are resolved with symlink awareness. Traversing a link that escapes the
project requires
external_directory; removing or renaming the link itself is internal. - Archive extraction rejects absolute paths,
.., and escaping symlinks. http_requestaccepts onlyhttp:andhttps:, keeps TLS verification on, re-checks permissions on redirect hops, and writes downloads to a temporary file before renaming. Localhost and private addresses are not blocked by default.rmis permanent.trashuses the XDG home trash and refuses cross-device moves instead of silently copying.- The sandbox is a guardrail, not a kernel security boundary. Symlink races remain possible.
Development
See AGENTS.md for the engineering rules: async-only I/O, core-count concurrency, and where every independent operation must be parallelized.
npm install
npm run typecheck
npm test
npm run coveragenpm run coverage enforces 100% statements, branches, functions and lines.
A small number of OS-error branches are marked with /* v8 ignore next */ when
they cannot be produced deterministically; all tool logic and normal error
paths are exercised.
Releases
Every push to main runs .github/workflows/release.yml. It reads the version
from package.json, runs typecheck and the 100% coverage gate, packs the npm
tarball, uploads it as a workflow artifact, publishes it to npm with provenance
when the version is new, and creates a GitHub release tagged v<version> when
that release does not exist yet. Bump the version in package.json to publish a
new release; an unchanged version is skipped with a notice.
The npm step uses trusted publishing (OIDC); no npm token is stored. The
package's trusted publisher must authorize workflow release.yml in
masterjg/opencode-fs-plugin. Keep Allow npm publish enabled on that
connection: the workflow uses npm publish, not npm stage publish, so
releases publish directly without per-version approval.
License
EPL-2.0. The original project and this fork remain under the same license.
