runspec-fs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runspec_fs/__init__.py +48 -0
- runspec_fs/cli.py +95 -0
- runspec_fs/runspec.toml +123 -0
- runspec_fs-0.1.0.dist-info/METADATA +11 -0
- runspec_fs-0.1.0.dist-info/RECORD +7 -0
- runspec_fs-0.1.0.dist-info/WHEEL +4 -0
- runspec_fs-0.1.0.dist-info/entry_points.txt +2 -0
runspec_fs/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""runspec-fs — filesystem operations as a runspec runnable (the ``fs`` runnable).
|
|
2
|
+
|
|
3
|
+
Ported from the
|
|
4
|
+
`Filesystem MCP server <https://github.com/modelcontextprotocol/servers/tree/HEAD/src/filesystem>`_.
|
|
5
|
+
The pure logic lives in ``runspec-fs-core`` (importable with **no** runspec
|
|
6
|
+
dependency); this package wraps each helper as a verb of the ``fs`` runnable and
|
|
7
|
+
is what ``runspec local`` / ``runspec serve`` discover.
|
|
8
|
+
|
|
9
|
+
The core helpers are re-exported here for convenience / back-compat, so a caller
|
|
10
|
+
that already depends on ``runspec-fs`` can ``from runspec_fs import
|
|
11
|
+
read_text_file`` without also naming ``runspec-fs-core``:
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from runspec_fs_core import (
|
|
15
|
+
EditNotAppliedError,
|
|
16
|
+
FsCoreError,
|
|
17
|
+
allowed_directories,
|
|
18
|
+
create_directory,
|
|
19
|
+
directory_tree,
|
|
20
|
+
edit_file,
|
|
21
|
+
get_file_info,
|
|
22
|
+
list_directory,
|
|
23
|
+
list_directory_with_sizes,
|
|
24
|
+
move_file,
|
|
25
|
+
read_media_file,
|
|
26
|
+
read_multiple_files,
|
|
27
|
+
read_text_file,
|
|
28
|
+
search_files,
|
|
29
|
+
write_file,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"FsCoreError",
|
|
34
|
+
"EditNotAppliedError",
|
|
35
|
+
"read_text_file",
|
|
36
|
+
"read_media_file",
|
|
37
|
+
"read_multiple_files",
|
|
38
|
+
"write_file",
|
|
39
|
+
"edit_file",
|
|
40
|
+
"create_directory",
|
|
41
|
+
"list_directory",
|
|
42
|
+
"list_directory_with_sizes",
|
|
43
|
+
"directory_tree",
|
|
44
|
+
"move_file",
|
|
45
|
+
"search_files",
|
|
46
|
+
"get_file_info",
|
|
47
|
+
"allowed_directories",
|
|
48
|
+
]
|
runspec_fs/cli.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""cli.py — the ``fs`` entry point.
|
|
2
|
+
|
|
3
|
+
One binary, a verb per Filesystem MCP tool. ``rs.parse("fs")`` resolves the
|
|
4
|
+
subcommand and its args; we dispatch on ``spec.runspec_command``, unpack each
|
|
5
|
+
``.value`` at the boundary, and delegate the actual work to
|
|
6
|
+
:mod:`runspec_fs_core`. Failures (the ``runspec_fs_core`` errors, the stdlib
|
|
7
|
+
``OSError`` family, and arg-shape ``ValueError``s such as an ``--old-text`` /
|
|
8
|
+
``--new-text`` count mismatch) become a JSON ``{"error": ...}`` payload + a
|
|
9
|
+
non-zero exit — the shape the CLI/agent surface expects.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import runspec as rs
|
|
19
|
+
from runspec_fs_core import (
|
|
20
|
+
allowed_directories,
|
|
21
|
+
create_directory,
|
|
22
|
+
directory_tree,
|
|
23
|
+
edit_file,
|
|
24
|
+
get_file_info,
|
|
25
|
+
list_directory,
|
|
26
|
+
list_directory_with_sizes,
|
|
27
|
+
move_file,
|
|
28
|
+
read_media_file,
|
|
29
|
+
read_multiple_files,
|
|
30
|
+
read_text_file,
|
|
31
|
+
search_files,
|
|
32
|
+
write_file,
|
|
33
|
+
)
|
|
34
|
+
from runspec_fs_core.errors import FsCoreError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _emit(payload: dict[str, Any]) -> None:
|
|
38
|
+
print(json.dumps(payload, default=str))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _pair_edits(old_text: list[str], new_text: list[str]) -> list[tuple[str, str]]:
|
|
42
|
+
"""Zip the two parallel ``--old-text`` / ``--new-text`` lists into
|
|
43
|
+
``(old, new)`` pairs, erroring if their counts differ."""
|
|
44
|
+
if len(old_text) != len(new_text):
|
|
45
|
+
raise ValueError(f"--old-text / --new-text count mismatch: {len(old_text)} vs {len(new_text)} (pair them by position)")
|
|
46
|
+
return list(zip(old_text, new_text, strict=False))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dispatch(spec: rs.RunSpec) -> dict[str, Any]:
|
|
50
|
+
command = spec.runspec_command
|
|
51
|
+
|
|
52
|
+
if command == "read-text-file":
|
|
53
|
+
return read_text_file(spec.path.value, head=spec.head.value, tail=spec.tail.value)
|
|
54
|
+
if command == "read-media-file":
|
|
55
|
+
return read_media_file(spec.path.value)
|
|
56
|
+
if command == "read-multiple-files":
|
|
57
|
+
return read_multiple_files(spec.paths.value)
|
|
58
|
+
if command == "list-directory":
|
|
59
|
+
return list_directory(spec.path.value)
|
|
60
|
+
if command == "list-directory-with-sizes":
|
|
61
|
+
return list_directory_with_sizes(spec.path.value, sort_by=spec.sort_by.value)
|
|
62
|
+
if command == "directory-tree":
|
|
63
|
+
return directory_tree(spec.path.value, exclude=spec.exclude.value or None)
|
|
64
|
+
if command == "search-files":
|
|
65
|
+
return search_files(spec.path.value, spec.pattern.value, exclude=spec.exclude.value or None)
|
|
66
|
+
if command == "get-file-info":
|
|
67
|
+
return get_file_info(spec.path.value)
|
|
68
|
+
if command == "list-allowed-directories":
|
|
69
|
+
return allowed_directories()
|
|
70
|
+
if command == "write-file":
|
|
71
|
+
return write_file(spec.path.value, spec.content.value)
|
|
72
|
+
if command == "edit-file":
|
|
73
|
+
edits = _pair_edits(spec.old_text.value, spec.new_text.value)
|
|
74
|
+
return edit_file(spec.path.value, edits, dry_run=spec.dry_run.value)
|
|
75
|
+
if command == "create-directory":
|
|
76
|
+
return create_directory(spec.path.value)
|
|
77
|
+
if command == "move-file":
|
|
78
|
+
return move_file(spec.source.value, spec.destination.value)
|
|
79
|
+
|
|
80
|
+
# require-command = true makes a bare invocation unreachable.
|
|
81
|
+
raise SystemExit("no command — see `fs --help`") # pragma: no cover
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main() -> None:
|
|
85
|
+
spec = rs.parse("fs")
|
|
86
|
+
try:
|
|
87
|
+
result = _dispatch(spec)
|
|
88
|
+
except (FsCoreError, OSError, ValueError) as exc:
|
|
89
|
+
_emit({"error": str(exc), "kind": type(exc).__name__})
|
|
90
|
+
sys.exit(1)
|
|
91
|
+
_emit(result)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
if __name__ == "__main__":
|
|
95
|
+
main()
|
runspec_fs/runspec.toml
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#:schema https://runspec.app/runspec.schema.json
|
|
2
|
+
|
|
3
|
+
# runspec-fs — filesystem operations as a runspec runnable, ported from the
|
|
4
|
+
# Filesystem MCP server:
|
|
5
|
+
# https://github.com/modelcontextprotocol/servers/tree/HEAD/src/filesystem
|
|
6
|
+
#
|
|
7
|
+
# One runnable, `fs`, with a verb per MCP tool. `runspec serve` re-expands each
|
|
8
|
+
# verb into its own leaf MCP tool (fs_read-text-file, fs_write-file, …), so a
|
|
9
|
+
# runspec-console agent calls them individually — over SSH on any fleet host,
|
|
10
|
+
# giving local AND remote filesystem access with per-verb confirm gates.
|
|
11
|
+
#
|
|
12
|
+
# No directory jail is enforced: access is bounded by the OS permissions of the
|
|
13
|
+
# user the process runs as (the logged-in user, or a `run_as` service account).
|
|
14
|
+
# A wrong path the agent picks and a human confirms still can't exceed what that
|
|
15
|
+
# user could already do on the host.
|
|
16
|
+
|
|
17
|
+
[config]
|
|
18
|
+
autonomy-default = "confirm"
|
|
19
|
+
|
|
20
|
+
[config.logging]
|
|
21
|
+
store = "per-run"
|
|
22
|
+
|
|
23
|
+
[fs]
|
|
24
|
+
description = "Read and manipulate the filesystem on this host (local, or remote via runspec-console)"
|
|
25
|
+
output = "json"
|
|
26
|
+
require-command = true
|
|
27
|
+
examples = [
|
|
28
|
+
{cmd = "fs list-directory --path .", description = "List the current directory"},
|
|
29
|
+
{cmd = "fs read-text-file --path README.md --head 20", description = "Read the first 20 lines of a file"},
|
|
30
|
+
{cmd = "fs search-files --path . --pattern '*.py'", description = "Find Python files recursively"},
|
|
31
|
+
{cmd = "fs edit-file --path a.txt --old-text foo --new-text bar", description = "Replace 'foo' with 'bar'"},
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
# ── reads / metadata → autonomous ──────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
[fs.commands.read-text-file]
|
|
37
|
+
description = "Read a file as UTF-8 text; optionally only the first N (--head) or last N (--tail) lines"
|
|
38
|
+
autonomy = "autonomous"
|
|
39
|
+
[fs.commands.read-text-file.args]
|
|
40
|
+
path = {type = "path", required = true, description = "File to read"}
|
|
41
|
+
head = {type = "int", required = false, description = "Return only the first N lines"}
|
|
42
|
+
tail = {type = "int", required = false, description = "Return only the last N lines"}
|
|
43
|
+
|
|
44
|
+
[fs.commands.read-media-file]
|
|
45
|
+
description = "Read a binary/media file and return it base64-encoded with its MIME type"
|
|
46
|
+
autonomy = "autonomous"
|
|
47
|
+
[fs.commands.read-media-file.args]
|
|
48
|
+
path = {type = "path", required = true, description = "File to read"}
|
|
49
|
+
|
|
50
|
+
[fs.commands.read-multiple-files]
|
|
51
|
+
description = "Read several files at once; a failed read is reported per-file without aborting the batch"
|
|
52
|
+
autonomy = "autonomous"
|
|
53
|
+
[fs.commands.read-multiple-files.args]
|
|
54
|
+
paths = {type = "path", multiple = true, required = true, description = "Files to read (repeat --paths per file)"}
|
|
55
|
+
|
|
56
|
+
[fs.commands.list-directory]
|
|
57
|
+
description = "List a directory's immediate contents, each tagged file or directory"
|
|
58
|
+
autonomy = "autonomous"
|
|
59
|
+
[fs.commands.list-directory.args]
|
|
60
|
+
path = {type = "path", required = true, description = "Directory to list"}
|
|
61
|
+
|
|
62
|
+
[fs.commands.list-directory-with-sizes]
|
|
63
|
+
description = "List a directory's contents with per-entry sizes and totals"
|
|
64
|
+
autonomy = "autonomous"
|
|
65
|
+
[fs.commands.list-directory-with-sizes.args]
|
|
66
|
+
path = {type = "path", required = true, description = "Directory to list"}
|
|
67
|
+
sort-by = {type = "choice", options = ["name", "size"], default = "name", description = "Sort order (size is descending)"}
|
|
68
|
+
|
|
69
|
+
[fs.commands.directory-tree]
|
|
70
|
+
description = "Return a recursive JSON tree of a directory"
|
|
71
|
+
autonomy = "autonomous"
|
|
72
|
+
[fs.commands.directory-tree.args]
|
|
73
|
+
path = {type = "path", required = true, description = "Directory to walk"}
|
|
74
|
+
exclude = {type = "str", multiple = true, required = false, description = "Glob patterns to prune (repeat --exclude)"}
|
|
75
|
+
|
|
76
|
+
[fs.commands.search-files]
|
|
77
|
+
description = "Recursively find files/directories whose name matches a pattern (glob, or case-insensitive substring)"
|
|
78
|
+
autonomy = "autonomous"
|
|
79
|
+
[fs.commands.search-files.args]
|
|
80
|
+
path = {type = "path", required = true, description = "Directory to search under"}
|
|
81
|
+
pattern = {type = "str", required = true, description = "Glob (e.g. '*.py') or a case-insensitive substring"}
|
|
82
|
+
exclude = {type = "str", multiple = true, required = false, description = "Glob patterns to prune (repeat --exclude)"}
|
|
83
|
+
|
|
84
|
+
[fs.commands.get-file-info]
|
|
85
|
+
description = "Return detailed metadata for a file or directory (size, timestamps, permissions)"
|
|
86
|
+
autonomy = "autonomous"
|
|
87
|
+
[fs.commands.get-file-info.args]
|
|
88
|
+
path = {type = "path", required = true, description = "File or directory to stat"}
|
|
89
|
+
|
|
90
|
+
[fs.commands.list-allowed-directories]
|
|
91
|
+
description = "Report the effective user and starting directories (informational — no directory jail is enforced)"
|
|
92
|
+
autonomy = "autonomous"
|
|
93
|
+
|
|
94
|
+
# ── mutations → confirm ────────────────────────────────────────────────────
|
|
95
|
+
|
|
96
|
+
[fs.commands.write-file]
|
|
97
|
+
description = "Create a new file or overwrite an existing one with UTF-8 text"
|
|
98
|
+
autonomy = "confirm"
|
|
99
|
+
[fs.commands.write-file.args]
|
|
100
|
+
path = {type = "path", required = true, description = "File to write"}
|
|
101
|
+
content = {type = "str", required = true, description = "File contents"}
|
|
102
|
+
|
|
103
|
+
[fs.commands.edit-file]
|
|
104
|
+
description = "Apply ordered old→new substring replacements to a file; returns a unified diff. Pair each --old-text with a --new-text by position."
|
|
105
|
+
autonomy = "confirm"
|
|
106
|
+
[fs.commands.edit-file.args]
|
|
107
|
+
path = {type = "path", required = true, description = "File to edit"}
|
|
108
|
+
old-text = {type = "str", multiple = true, required = true, description = "Text to find (repeat --old-text; paired by position with --new-text)"}
|
|
109
|
+
new-text = {type = "str", multiple = true, required = true, description = "Replacement text (repeat --new-text; paired by position with --old-text)"}
|
|
110
|
+
dry-run = {type = "flag", default = false, description = "Compute and diff the edits without writing"}
|
|
111
|
+
|
|
112
|
+
[fs.commands.create-directory]
|
|
113
|
+
description = "Create a directory (and any missing parents); idempotent"
|
|
114
|
+
autonomy = "confirm"
|
|
115
|
+
[fs.commands.create-directory.args]
|
|
116
|
+
path = {type = "path", required = true, description = "Directory to create"}
|
|
117
|
+
|
|
118
|
+
[fs.commands.move-file]
|
|
119
|
+
description = "Move or rename a file/directory; fails if the destination already exists"
|
|
120
|
+
autonomy = "confirm"
|
|
121
|
+
[fs.commands.move-file.args]
|
|
122
|
+
source = {type = "path", required = true, description = "Path to move"}
|
|
123
|
+
destination = {type = "path", required = true, description = "New path (must not already exist)"}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runspec-fs
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Filesystem runnable for runspec — read and manipulate local/remote filesystems, ported from the Filesystem MCP server
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: runspec-fs-core>=0.1.0
|
|
7
|
+
Requires-Dist: runspec>=0.44.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
10
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
11
|
+
Requires-Dist: ruff==0.15.20; extra == 'dev'
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
runspec_fs/__init__.py,sha256=mWLM-DPhfuSM5xyO6ohSM3giS_jQG-a1qWfte9llY4Q,1324
|
|
2
|
+
runspec_fs/cli.py,sha256=x3haDpoG7M8TkVAhQ80VOTcEhxjtNAQ37tVZo8fsg-g,3444
|
|
3
|
+
runspec_fs/runspec.toml,sha256=UcooLiyF8Eollgo86LjHarlmh3OT9prRnlPtzsSKYQY,6333
|
|
4
|
+
runspec_fs-0.1.0.dist-info/METADATA,sha256=9np0tqUY5-7m_LoA7UAkMSD649UGbI22rkMTXrgk7H0,420
|
|
5
|
+
runspec_fs-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
runspec_fs-0.1.0.dist-info/entry_points.txt,sha256=7cGeDLaU8E86XOc1dKjoOQylUgbILv7XUHlKJAtZd4w,43
|
|
7
|
+
runspec_fs-0.1.0.dist-info/RECORD,,
|